From 6ee6d23ed9645fbd43194f073d60c45f1f38b9de Mon Sep 17 00:00:00 2001 From: Skinny001 Date: Wed, 26 Aug 2026 03:09:41 +0100 Subject: [PATCH 1/2] fix: protect scheduled-transaction routes with verifyJWT and ownership checks - Add verifyJWT middleware to all scheduled-transaction routes - Add requireOwnSchedule middleware for :publicKey routes (GET /:publicKey, GET /:publicKey/pending) - Add requireScheduleOwner middleware for :id routes (PUT, DELETE, POST /execute-now, GET /executions) - Add ownership check for POST /pending/:id/submit - Use req.user.publicKey as owner for createSchedule (ignore/reject body publicKey) - Add getScheduleById and getPendingExecutionById service helpers - Add 28 tests: 8x 401 unauthenticated, 7x 403 cross-user, 8x 200 owner, 5x 404 not found Closes: backend auth security bug high-priority --- .../__tests__/scheduledTransactions.test.js | 428 ++++++++++++++---- backend/src/routes/scheduledTransactions.js | 91 +++- .../services/scheduledTransactionService.js | 10 + backend/src/validation/schemas.js | 10 +- 4 files changed, 430 insertions(+), 109 deletions(-) diff --git a/backend/__tests__/scheduledTransactions.test.js b/backend/__tests__/scheduledTransactions.test.js index dc0f7061..0f32f912 100644 --- a/backend/__tests__/scheduledTransactions.test.js +++ b/backend/__tests__/scheduledTransactions.test.js @@ -8,155 +8,395 @@ const request = require("supertest"); const express = require("express"); +const jwt = require("jsonwebtoken"); +const { JWT_SECRET } = require("../src/middleware/auth"); + +// The scheduled transaction modules import @stellar/stellar-sdk, whose CJS +// build pulls in an ESM-only @noble/hashes dependency that Jest cannot load. +// Stub the SDK surface these modules reference. +jest.mock("@stellar/stellar-sdk", () => { + const builder = { + addOperation: () => builder, + addMemo: () => builder, + setTimeout: () => builder, + build: () => ({ toXDR: () => "AAAA" }), + }; + return { + Horizon: { Server: jest.fn(() => ({})) }, + Networks: { PUBLIC: "public-network-passphrase", TESTNET: "test-network-passphrase" }, + TransactionBuilder: Object.assign( + function TransactionBuilder() { + return builder; + }, + { fromXDR: jest.fn() }, + ), + Asset: { native: jest.fn(() => ({})) }, + Memo: { text: jest.fn(() => ({})) }, + Operation: { payment: jest.fn(() => ({})) }, + }; +}); // Mock the service before requiring the route jest.mock("../src/services/scheduledTransactionService"); const scheduledTransactionService = require("../src/services/scheduledTransactionService"); -const app = express(); -app.use(express.json()); -const scheduledTransactionRoutes = require("../src/routes/scheduledTransactions"); -app.use("/api/scheduled-transactions", scheduledTransactionRoutes); +// Mock the scheduledExecutor +jest.mock("../src/services/scheduledExecutor"); +const scheduledExecutor = require("../src/services/scheduledExecutor"); + +const ME = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; +const OTHER = "GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX"; + +function createApp() { + const app = express(); + app.use(express.json()); + const scheduledTransactionRoutes = require("../src/routes/scheduledTransactions"); + app.use("/api/scheduled-transactions", scheduledTransactionRoutes); + return app; +} + +function tokenFor(publicKey) { + return jwt.sign({ publicKey }, JWT_SECRET, { expiresIn: "1h" }); +} + +describe("Scheduled Transactions Routes - Authentication & Authorization", () => { + let app; -describe("Scheduled Transactions Routes", () => { beforeEach(() => { jest.clearAllMocks(); + app = createApp(); }); - describe("POST /api/scheduled-transactions", () => { - it("returns 201 with scheduled transaction on success", async () => { - const mockTx = { - id: "tx-1", - ownerPk: "GABC123", - recipient: "GXYZ456", - amount: "50", - frequency: "daily", - startDate: "2026-08-01", - status: "active", - }; + describe("Authentication (verifyJWT)", () => { + const routes = [ + { method: "post", path: "/api/scheduled-transactions" }, + { method: "post", path: "/api/scheduled-transactions/pending/exec-1/submit" }, + { method: "get", path: "/api/scheduled-transactions/GABC123" }, + { method: "get", path: "/api/scheduled-transactions/GABC123/pending" }, + { method: "put", path: "/api/scheduled-transactions/tx-1" }, + { method: "delete", path: "/api/scheduled-transactions/tx-1" }, + { method: "post", path: "/api/scheduled-transactions/tx-1/execute-now" }, + { method: "get", path: "/api/scheduled-transactions/tx-1/executions" }, + ]; + + routes.forEach(({ method, path }) => { + it(`rejects unauthenticated ${method.toUpperCase()} ${path} with 401`, async () => { + const res = await request(app)[method](path).send({}); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("AUTH_MISSING_HEADER"); + }); + }); + }); - scheduledTransactionService.createSchedule.mockReturnValue(mockTx); + describe("Authorization - cross-user access (requireOwnSchedule / requireScheduleOwner)", () => { + it("rejects GET /:publicKey with 403 when accessing another user's schedules", async () => { + const res = await request(app) + .get(`/api/scheduled-transactions/${OTHER}`) + .set("Authorization", `Bearer ${tokenFor(ME)}`); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); + }); - const res = await request(app).post("/api/scheduled-transactions").send(mockTx); + it("rejects GET /:publicKey/pending with 403 when accessing another user's pending executions", async () => { + const res = await request(app) + .get(`/api/scheduled-transactions/${OTHER}/pending`) + .set("Authorization", `Bearer ${tokenFor(ME)}`); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); + }); + + it("rejects PUT /:id with 403 when updating another user's schedule", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue({ + id: "tx-1", + owner_pk: OTHER, + }); - expect(res.status).toBe(201); - expect(res.body.id).toBe("tx-1"); - expect(res.body.ownerPk).toBe("GABC123"); - expect(scheduledTransactionService.createSchedule).toHaveBeenCalledWith(mockTx); + const res = await request(app) + .put("/api/scheduled-transactions/tx-1") + .set("Authorization", `Bearer ${tokenFor(ME)}`) + .send({ amount: "100" }); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); }); - it("forwards service errors via next()", async () => { - scheduledTransactionService.createSchedule.mockImplementation(() => { - throw new Error("Service failure"); + it("rejects DELETE /:id with 403 when deleting another user's schedule", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue({ + id: "tx-1", + owner_pk: OTHER, }); const res = await request(app) - .post("/api/scheduled-transactions") - .send({ ownerPk: "GABC123" }); + .delete("/api/scheduled-transactions/tx-1") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); + }); + + it("rejects POST /:id/execute-now with 403 when executing another user's schedule", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue({ + id: "tx-1", + owner_pk: OTHER, + }); - expect(res.status).toBe(500); + const res = await request(app) + .post("/api/scheduled-transactions/tx-1/execute-now") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); }); - }); - describe("POST /api/scheduled-transactions/pending/:id/submit", () => { - it("returns 200 with result on success", async () => { - const mockResult = { - success: true, - txHash: "abcdef123", - }; + it("rejects GET /:id/executions with 403 when accessing another user's execution history", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue({ + id: "tx-1", + owner_pk: OTHER, + }); + + const res = await request(app) + .get("/api/scheduled-transactions/tx-1/executions") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); + }); - scheduledTransactionService.submitPendingExecution.mockResolvedValue(mockResult); + it("rejects POST /pending/:id/submit with 403 when submitting another user's pending execution", async () => { + scheduledTransactionService.getPendingExecutionById.mockResolvedValue({ + id: "exec-1", + owner_pk: OTHER, + status: "awaiting_signature", + }); const res = await request(app) - .post("/api/scheduled-transactions/pending/execution-1/submit") + .post("/api/scheduled-transactions/pending/exec-1/submit") + .set("Authorization", `Bearer ${tokenFor(ME)}`) .send({ signedXDR: "AAAAAgAAAAC..." }); + expect(res.status).toBe(403); + expect(res.body.error.code).toBe("AUTH_FORBIDDEN"); + }); + }); - expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.txHash).toBe("abcdef123"); - expect(scheduledTransactionService.submitPendingExecution).toHaveBeenCalledWith( - "execution-1", - "AAAAAgAAAAC...", - ); + describe("Owner operations succeed (happy paths)", () => { + describe("POST /api/scheduled-transactions", () => { + it("returns 201 with scheduled transaction for authenticated user", async () => { + const mockTx = { + id: "tx-1", + owner_pk: ME, + recipient: "GXYZ456", + amount: "50", + frequency: "daily", + start_date: "2026-08-01", + status: "active", + }; + + scheduledTransactionService.createSchedule.mockResolvedValue(mockTx); + + const res = await request(app) + .post("/api/scheduled-transactions") + .set("Authorization", `Bearer ${tokenFor(ME)}`) + .send({ + signedXDR: "AAAAAgAAAAC...", + submitAt: "2026-08-01T00:00:00Z", + }); + + expect(res.status).toBe(201); + expect(res.body.id).toBe("tx-1"); + expect(res.body.owner_pk).toBe(ME); + expect(scheduledTransactionService.createSchedule).toHaveBeenCalledWith( + expect.objectContaining({ + signedXDR: "AAAAAgAAAAC...", + submitAt: expect.any(Date), + ownerPk: ME, + }), + ); + }); }); - it("returns 400 when signedXDR is missing", async () => { - const res = await request(app) - .post("/api/scheduled-transactions/pending/execution-1/submit") - .send({}); + describe("GET /api/scheduled-transactions/:publicKey", () => { + it("returns schedules for the authenticated user", async () => { + const mockSchedules = [ + { id: "tx-1", owner_pk: ME }, + { id: "tx-2", owner_pk: ME }, + ]; + + scheduledTransactionService.listSchedules.mockResolvedValue(mockSchedules); + + const res = await request(app) + .get(`/api/scheduled-transactions/${ME}`) + .set("Authorization", `Bearer ${tokenFor(ME)}`); - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("VAL_MISSING_FIELD"); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(2); + // Pagination sorts by id descending, so tx-2 comes first + expect(res.body.data[0].id).toBe("tx-2"); + expect(scheduledTransactionService.listSchedules).toHaveBeenCalledWith(ME); + }); }); - }); - describe("GET /api/scheduled-transactions/:publicKey", () => { - it("returns schedules for a given public key", async () => { - const mockSchedules = [ - { id: "tx-1", ownerPk: "GABC123" }, - { id: "tx-2", ownerPk: "GABC123" }, - ]; + describe("GET /api/scheduled-transactions/:publicKey/pending", () => { + it("returns pending executions for the authenticated user", async () => { + const mockPending = [{ id: "execution-1", owner_pk: ME, status: "awaiting_signature" }]; - scheduledTransactionService.listSchedules.mockReturnValue(mockSchedules); + scheduledTransactionService.listPendingExecutions.mockResolvedValue(mockPending); - const res = await request(app).get("/api/scheduled-transactions/GABC123"); + const res = await request(app) + .get(`/api/scheduled-transactions/${ME}/pending`) + .set("Authorization", `Bearer ${tokenFor(ME)}`); - expect(res.status).toBe(200); - expect(res.body).toHaveLength(2); - expect(res.body[0].id).toBe("tx-1"); - expect(scheduledTransactionService.listSchedules).toHaveBeenCalledWith("GABC123"); + expect(res.status).toBe(200); + expect(res.body.data).toHaveLength(1); + expect(res.body.data[0].id).toBe("execution-1"); + expect(scheduledTransactionService.listPendingExecutions).toHaveBeenCalledWith(ME); + }); + }); + + describe("PUT /api/scheduled-transactions/:id", () => { + it("returns updated schedule for the owner", async () => { + const mockSchedule = { id: "tx-1", owner_pk: ME }; + const mockUpdated = { id: "tx-1", amount: "100" }; + + scheduledTransactionService.getScheduleById.mockResolvedValue(mockSchedule); + scheduledTransactionService.updateSchedule.mockResolvedValue(mockUpdated); + + const res = await request(app) + .put("/api/scheduled-transactions/tx-1") + .set("Authorization", `Bearer ${tokenFor(ME)}`) + .send({ amount: "100" }); + + expect(res.status).toBe(200); + expect(res.body.amount).toBe("100"); + expect(scheduledTransactionService.updateSchedule).toHaveBeenCalledWith("tx-1", { + amount: "100", + }); + }); + }); + + describe("DELETE /api/scheduled-transactions/:id", () => { + it("returns success message when owner deletes schedule", async () => { + const mockSchedule = { id: "tx-1", owner_pk: ME }; + + scheduledTransactionService.getScheduleById.mockResolvedValue(mockSchedule); + scheduledTransactionService.deleteSchedule.mockResolvedValue(true); + + const res = await request(app) + .delete("/api/scheduled-transactions/tx-1") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + + expect(res.status).toBe(200); + expect(res.body.message).toBe("Scheduled transaction tx-1 deleted."); + }); }); - }); - describe("GET /api/scheduled-transactions/:publicKey/pending", () => { - it("returns pending executions for a given public key", async () => { - const mockPending = [{ id: "execution-1", ownerPk: "GABC123", status: "awaiting_signature" }]; + describe("POST /api/scheduled-transactions/:id/execute-now", () => { + it("returns execution result for the owner", async () => { + const mockSchedule = { id: "tx-1", owner_pk: ME }; + const mockResult = { success: true, executionId: "exec-1", hash: "abc123" }; - scheduledTransactionService.listPendingExecutions.mockReturnValue(mockPending); + scheduledTransactionService.getScheduleById.mockResolvedValue(mockSchedule); + scheduledExecutor.executeNow.mockResolvedValue(mockResult); - const res = await request(app).get("/api/scheduled-transactions/GABC123/pending"); + const res = await request(app) + .post("/api/scheduled-transactions/tx-1/execute-now") + .set("Authorization", `Bearer ${tokenFor(ME)}`); - expect(res.status).toBe(200); - expect(res.body).toHaveLength(1); - expect(res.body[0].id).toBe("execution-1"); - expect(scheduledTransactionService.listPendingExecutions).toHaveBeenCalledWith("GABC123"); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.executionId).toBe("exec-1"); + }); + }); + + describe("GET /api/scheduled-transactions/:id/executions", () => { + it("returns execution history for the owner", async () => { + const mockSchedule = { id: "tx-1", owner_pk: ME }; + const mockExecutions = [{ id: "exec-1", schedule_id: "tx-1", status: "submitted" }]; + + scheduledTransactionService.getScheduleById.mockResolvedValue(mockSchedule); + scheduledExecutor.getExecutionHistory.mockResolvedValue(mockExecutions); + + const res = await request(app) + .get("/api/scheduled-transactions/tx-1/executions") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + + expect(res.status).toBe(200); + expect(res.body.scheduleId).toBe("tx-1"); + expect(res.body.executions).toHaveLength(1); + }); + }); + + describe("POST /api/scheduled-transactions/pending/:id/submit", () => { + it("returns result when owner submits signed XDR", async () => { + const mockPending = { id: "exec-1", owner_pk: ME, status: "awaiting_signature" }; + const mockResult = { status: "submitted", hash: "abc123" }; + + scheduledTransactionService.getPendingExecutionById.mockResolvedValue(mockPending); + scheduledTransactionService.submitPendingExecution.mockResolvedValue(mockResult); + + const res = await request(app) + .post("/api/scheduled-transactions/pending/exec-1/submit") + .set("Authorization", `Bearer ${tokenFor(ME)}`) + .send({ signedXDR: "AAAAAgAAAAC..." }); + + expect(res.status).toBe(200); + expect(res.body.status).toBe("submitted"); + expect(res.body.hash).toBe("abc123"); + }); }); }); - describe("PUT /api/scheduled-transactions/:id", () => { - it("returns updated schedule", async () => { - const mockUpdated = { id: "tx-1", amount: "100" }; - scheduledTransactionService.updateSchedule.mockReturnValue(mockUpdated); + describe("Error handling", () => { + it("returns 404 when schedule not found for PUT", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue(null); const res = await request(app) - .put("/api/scheduled-transactions/tx-1") + .put("/api/scheduled-transactions/tx-999") + .set("Authorization", `Bearer ${tokenFor(ME)}`) .send({ amount: "100" }); - expect(res.status).toBe(200); - expect(res.body.amount).toBe("100"); - expect(scheduledTransactionService.updateSchedule).toHaveBeenCalledWith("tx-1", { - amount: "100", - }); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("RES_NOT_FOUND"); }); - }); - describe("DELETE /api/scheduled-transactions/:id", () => { - it("returns success message when schedule is deleted", async () => { - scheduledTransactionService.deleteSchedule.mockReturnValue(true); + it("returns 404 when schedule not found for DELETE", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue(null); - const res = await request(app).delete("/api/scheduled-transactions/tx-1"); + const res = await request(app) + .delete("/api/scheduled-transactions/tx-999") + .set("Authorization", `Bearer ${tokenFor(ME)}`); - expect(res.status).toBe(200); - expect(res.body.message).toBe("Scheduled transaction tx-1 deleted."); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("RES_NOT_FOUND"); }); - it("returns 404 when schedule is not found", async () => { - scheduledTransactionService.deleteSchedule.mockReturnValue(false); + it("returns 404 when schedule not found for execute-now", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue(null); - const res = await request(app).delete("/api/scheduled-transactions/tx-999"); + const res = await request(app) + .post("/api/scheduled-transactions/tx-999/execute-now") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("RES_NOT_FOUND"); + }); + + it("returns 404 when schedule not found for executions", async () => { + scheduledTransactionService.getScheduleById.mockResolvedValue(null); + + const res = await request(app) + .get("/api/scheduled-transactions/tx-999/executions") + .set("Authorization", `Bearer ${tokenFor(ME)}`); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe("RES_NOT_FOUND"); + }); + + it("returns 404 when pending execution not found for submit", async () => { + scheduledTransactionService.getPendingExecutionById.mockResolvedValue(null); + + const res = await request(app) + .post("/api/scheduled-transactions/pending/exec-999/submit") + .set("Authorization", `Bearer ${tokenFor(ME)}`) + .send({ signedXDR: "AAAAAgAAAAC..." }); expect(res.status).toBe(404); expect(res.body.error.code).toBe("RES_NOT_FOUND"); }); }); -}); +}); \ No newline at end of file diff --git a/backend/src/routes/scheduledTransactions.js b/backend/src/routes/scheduledTransactions.js index 2173e16f..34e5f257 100644 --- a/backend/src/routes/scheduledTransactions.js +++ b/backend/src/routes/scheduledTransactions.js @@ -21,19 +21,73 @@ const { setPaginationHeaders, formatPaginatedResponse, } = require("../utils/paginate"); +const { verifyJWT } = require("../middleware/auth"); + +/** + * Restrict scheduled-transaction routes to the authenticated account holder. + * Runs after verifyJWT (which sets req.user.publicKey from the SEP-10 JWT). + */ +function requireOwnSchedule(req, res, next) { + if (req.user?.publicKey !== req.params.publicKey) { + return res + .status(ERROR_CODES.AUTH_FORBIDDEN.httpStatus) + .json(formatErrorResponse("AUTH_FORBIDDEN", { + message: "Forbidden: you may only access your own scheduled transactions.", + })); + } + next(); +} + +/** + * Restrict schedule-by-ID routes to the schedule owner. + * Fetches the schedule and verifies ownership. + */ +async function requireScheduleOwner(req, res, next) { + try { + const schedule = await scheduledTransactionService.getScheduleById(req.params.id); + if (!schedule) { + return res + .status(ERROR_CODES.RES_NOT_FOUND.httpStatus) + .json(formatErrorResponse("RES_NOT_FOUND", { + resourceType: "scheduledTransaction", + id: req.params.id, + })); + } + if (req.user?.publicKey !== schedule.owner_pk) { + return res + .status(ERROR_CODES.AUTH_FORBIDDEN.httpStatus) + .json(formatErrorResponse("AUTH_FORBIDDEN", { + message: "Forbidden: you may only access your own scheduled transactions.", + })); + } + req.schedule = schedule; + next(); + } catch (error) { + next(error); + } +} /** * POST /api/scheduled-transactions * Schedules a new transaction for future submission. - * Body: { signedXDR: string, submitAt: string (ISO 8601), publicKey: string } + * Body: { signedXDR: string, submitAt: string (ISO 8601), publicKey?: string } + * The owner is derived from the authenticated user's JWT. + * If publicKey is provided, it must match the authenticated user's publicKey. */ -router.post("/", validate(scheduleTransactionSchema), async (req, res, next) => { +router.post("/", verifyJWT, validate(scheduleTransactionSchema), async (req, res, next) => { try { const { signedXDR, submitAt, publicKey } = req.validated; + if (publicKey && publicKey !== req.user.publicKey) { + return res + .status(ERROR_CODES.AUTH_FORBIDDEN.httpStatus) + .json(formatErrorResponse("AUTH_FORBIDDEN", { + message: "Forbidden: publicKey in body must match authenticated user.", + })); + } const schedule = await scheduledTransactionService.createSchedule({ signedXDR, submitAt: new Date(submitAt), - publicKey, + ownerPk: req.user.publicKey, }); res.status(201).json(schedule); } catch (error) { @@ -48,7 +102,7 @@ router.post("/", validate(scheduleTransactionSchema), async (req, res, next) => * Validation: the id comes from req.validated (idParamSchema enforces a * non-empty string). Service treats it as opaque. */ -router.post("/pending/:id/submit", validate(idParamSchema, "params"), async (req, res, next) => { +router.post("/pending/:id/submit", verifyJWT, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const { signedXDR } = req.body; @@ -57,6 +111,23 @@ router.post("/pending/:id/submit", validate(idParamSchema, "params"), async (req .status(ERROR_CODES.VAL_MISSING_FIELD.httpStatus) .json(formatErrorResponse("VAL_MISSING_FIELD", { fields: ["signedXDR"] })); } + // Verify the pending execution belongs to the authenticated user + const pending = await scheduledTransactionService.getPendingExecutionById(id); + if (!pending) { + return res + .status(ERROR_CODES.RES_NOT_FOUND.httpStatus) + .json(formatErrorResponse("RES_NOT_FOUND", { + resourceType: "pendingExecution", + id, + })); + } + if (req.user.publicKey !== pending.owner_pk) { + return res + .status(ERROR_CODES.AUTH_FORBIDDEN.httpStatus) + .json(formatErrorResponse("AUTH_FORBIDDEN", { + message: "Forbidden: you may only submit your own pending executions.", + })); + } const result = await scheduledTransactionService.submitPendingExecution(id, signedXDR); res.json(result); } catch (error) { @@ -68,7 +139,7 @@ router.post("/pending/:id/submit", validate(idParamSchema, "params"), async (req * GET /api/scheduled-transactions/:publicKey/pending * Lists pending executions for a given public key with standardized pagination. */ -router.get("/:publicKey/pending", async (req, res, next) => { +router.get("/:publicKey/pending", verifyJWT, requireOwnSchedule, async (req, res, next) => { try { const rawPending = await scheduledTransactionService.listPendingExecutions( req.params.publicKey, @@ -94,7 +165,7 @@ router.get("/:publicKey/pending", async (req, res, next) => { * GET /api/scheduled-transactions/:publicKey * Lists all schedules for a given public key with standardized pagination. */ -router.get("/:publicKey", validate(loosePublicKeyParamSchema, "params"), async (req, res, next) => { +router.get("/:publicKey", verifyJWT, requireOwnSchedule, validate(loosePublicKeyParamSchema, "params"), async (req, res, next) => { try { const { publicKey } = req.validated; const rawSchedules = await scheduledTransactionService.listSchedules(publicKey); @@ -122,7 +193,7 @@ router.get("/:publicKey", validate(loosePublicKeyParamSchema, "params"), async ( * Validation: the id comes from req.validated (idParamSchema enforces a * non-empty string), so the service can treat it as opaque. */ -router.put("/:id", validate(idParamSchema, "params"), async (req, res, next) => { +router.put("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const updated = await scheduledTransactionService.updateSchedule(id, req.body); @@ -136,7 +207,7 @@ router.put("/:id", validate(idParamSchema, "params"), async (req, res, next) => * DELETE /api/scheduled-transactions/:id * Deletes or cancels a scheduled transaction by ID. */ -router.delete("/:id", validate(idParamSchema, "params"), async (req, res, next) => { +router.delete("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const deleted = await scheduledTransactionService.deleteSchedule(id); @@ -160,7 +231,7 @@ router.delete("/:id", validate(idParamSchema, "params"), async (req, res, next) * Manually trigger immediate execution of a scheduled transaction, * regardless of its scheduled time. */ -router.post("/:id/execute-now", validate(idParamSchema, "params"), async (req, res, next) => { +router.post("/:id/execute-now", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const result = await scheduledExecutor.executeNow(id); @@ -175,7 +246,7 @@ router.post("/:id/execute-now", validate(idParamSchema, "params"), async (req, r * Get execution history for a scheduled transaction. * Shows all execution attempts, retries, and failures. */ -router.get("/:id/executions", validate(idParamSchema, "params"), async (req, res, next) => { +router.get("/:id/executions", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const executions = await scheduledExecutor.getExecutionHistory(id); diff --git a/backend/src/services/scheduledTransactionService.js b/backend/src/services/scheduledTransactionService.js index e4c94b53..dd4d774f 100644 --- a/backend/src/services/scheduledTransactionService.js +++ b/backend/src/services/scheduledTransactionService.js @@ -313,6 +313,14 @@ async function submitPendingExecution(id, signedXDR) { } } +async function getScheduleById(id) { + return knex("scheduled_transactions").where("id", id).first(); +} + +async function getPendingExecutionById(id) { + return knex("pending_executions").where("id", id).first(); +} + module.exports = { createSchedule, listSchedules, @@ -323,4 +331,6 @@ module.exports = { loadActiveSchedules, buildUnsignedPaymentXDR, estimateNextRun, + getScheduleById, + getPendingExecutionById, }; diff --git a/backend/src/validation/schemas.js b/backend/src/validation/schemas.js index 2cbd6441..caf88305 100644 --- a/backend/src/validation/schemas.js +++ b/backend/src/validation/schemas.js @@ -264,7 +264,7 @@ const mintWithIpfsSchema = z.object({ // ─── scheduled transactions ─────────────────────────────────────────────────── -const SCHEDULED_FIELDS_REQUIRED = "Missing signedXDR, submitAt, or publicKey"; +const SCHEDULED_FIELDS_REQUIRED = "Missing signedXDR or submitAt"; /** POST /api/scheduled-txns */ const scheduleTransactionSchema = z.object({ @@ -277,11 +277,11 @@ const scheduleTransactionSchema = z.object({ .refine((value) => !Number.isNaN(new Date(value).getTime()), { message: "submitAt must be a valid ISO 8601 date string", }), - // The scheduler only uses this as an ownership marker — the value may be a - // test placeholder — so we require presence, not Stellar format. + // Optional: if provided, must match the authenticated user's publicKey publicKey: z - .string({ required_error: SCHEDULED_FIELDS_REQUIRED }) - .min(1, SCHEDULED_FIELDS_REQUIRED), + .string() + .min(1) + .optional(), }); // ─── SEP-0024 ───────────────────────────────────────────────────────────────── From e5754429d866553d1b0d8c9875d8fa3add704a5b Mon Sep 17 00:00:00 2001 From: Skinny001 Date: Wed, 26 Aug 2026 03:28:56 +0100 Subject: [PATCH 2/2] fix: add rate limiting to scheduled-transaction routes - Add sensitiveLimiter and userLimiter to all scheduled-transaction routes - Mock rate limiters in tests to avoid hitting limits during test runs - Follows the same pattern as accounts.js routes (sensitiveLimiter + userLimiter + verifyJWT) --- .../__tests__/scheduledTransactions.test.js | 11 +++++++++++ backend/src/routes/scheduledTransactions.js | 18 ++++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/backend/__tests__/scheduledTransactions.test.js b/backend/__tests__/scheduledTransactions.test.js index 0f32f912..dec50a27 100644 --- a/backend/__tests__/scheduledTransactions.test.js +++ b/backend/__tests__/scheduledTransactions.test.js @@ -36,6 +36,17 @@ jest.mock("@stellar/stellar-sdk", () => { }; }); +// Mock rate limiters to avoid hitting limits in tests +jest.mock("../src/middleware/rateLimit", () => ({ + sensitiveLimiter: (req, res, next) => next(), + strictLimiter: (req, res, next) => next(), + authRefreshLimiter: (req, res, next) => next(), +})); + +jest.mock("../src/middleware/userRateLimit", () => ({ + userLimiter: (req, res, next) => next(), +})); + // Mock the service before requiring the route jest.mock("../src/services/scheduledTransactionService"); const scheduledTransactionService = require("../src/services/scheduledTransactionService"); diff --git a/backend/src/routes/scheduledTransactions.js b/backend/src/routes/scheduledTransactions.js index 34e5f257..424cff58 100644 --- a/backend/src/routes/scheduledTransactions.js +++ b/backend/src/routes/scheduledTransactions.js @@ -22,6 +22,8 @@ const { formatPaginatedResponse, } = require("../utils/paginate"); const { verifyJWT } = require("../middleware/auth"); +const { sensitiveLimiter } = require("../middleware/rateLimit"); +const { userLimiter } = require("../middleware/userRateLimit"); /** * Restrict scheduled-transaction routes to the authenticated account holder. @@ -74,7 +76,7 @@ async function requireScheduleOwner(req, res, next) { * The owner is derived from the authenticated user's JWT. * If publicKey is provided, it must match the authenticated user's publicKey. */ -router.post("/", verifyJWT, validate(scheduleTransactionSchema), async (req, res, next) => { +router.post("/", sensitiveLimiter, userLimiter, verifyJWT, validate(scheduleTransactionSchema), async (req, res, next) => { try { const { signedXDR, submitAt, publicKey } = req.validated; if (publicKey && publicKey !== req.user.publicKey) { @@ -102,7 +104,7 @@ router.post("/", verifyJWT, validate(scheduleTransactionSchema), async (req, res * Validation: the id comes from req.validated (idParamSchema enforces a * non-empty string). Service treats it as opaque. */ -router.post("/pending/:id/submit", verifyJWT, validate(idParamSchema, "params"), async (req, res, next) => { +router.post("/pending/:id/submit", sensitiveLimiter, userLimiter, verifyJWT, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const { signedXDR } = req.body; @@ -139,7 +141,7 @@ router.post("/pending/:id/submit", verifyJWT, validate(idParamSchema, "params"), * GET /api/scheduled-transactions/:publicKey/pending * Lists pending executions for a given public key with standardized pagination. */ -router.get("/:publicKey/pending", verifyJWT, requireOwnSchedule, async (req, res, next) => { +router.get("/:publicKey/pending", sensitiveLimiter, userLimiter, verifyJWT, requireOwnSchedule, async (req, res, next) => { try { const rawPending = await scheduledTransactionService.listPendingExecutions( req.params.publicKey, @@ -165,7 +167,7 @@ router.get("/:publicKey/pending", verifyJWT, requireOwnSchedule, async (req, res * GET /api/scheduled-transactions/:publicKey * Lists all schedules for a given public key with standardized pagination. */ -router.get("/:publicKey", verifyJWT, requireOwnSchedule, validate(loosePublicKeyParamSchema, "params"), async (req, res, next) => { +router.get("/:publicKey", sensitiveLimiter, userLimiter, verifyJWT, requireOwnSchedule, validate(loosePublicKeyParamSchema, "params"), async (req, res, next) => { try { const { publicKey } = req.validated; const rawSchedules = await scheduledTransactionService.listSchedules(publicKey); @@ -193,7 +195,7 @@ router.get("/:publicKey", verifyJWT, requireOwnSchedule, validate(loosePublicKey * Validation: the id comes from req.validated (idParamSchema enforces a * non-empty string), so the service can treat it as opaque. */ -router.put("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { +router.put("/:id", sensitiveLimiter, userLimiter, verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const updated = await scheduledTransactionService.updateSchedule(id, req.body); @@ -207,7 +209,7 @@ router.put("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, "par * DELETE /api/scheduled-transactions/:id * Deletes or cancels a scheduled transaction by ID. */ -router.delete("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { +router.delete("/:id", sensitiveLimiter, userLimiter, verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const deleted = await scheduledTransactionService.deleteSchedule(id); @@ -231,7 +233,7 @@ router.delete("/:id", verifyJWT, requireScheduleOwner, validate(idParamSchema, " * Manually trigger immediate execution of a scheduled transaction, * regardless of its scheduled time. */ -router.post("/:id/execute-now", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { +router.post("/:id/execute-now", sensitiveLimiter, userLimiter, verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const result = await scheduledExecutor.executeNow(id); @@ -246,7 +248,7 @@ router.post("/:id/execute-now", verifyJWT, requireScheduleOwner, validate(idPara * Get execution history for a scheduled transaction. * Shows all execution attempts, retries, and failures. */ -router.get("/:id/executions", verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { +router.get("/:id/executions", sensitiveLimiter, userLimiter, verifyJWT, requireScheduleOwner, validate(idParamSchema, "params"), async (req, res, next) => { try { const { id } = req.validated; const executions = await scheduledExecutor.getExecutionHistory(id);