From 9eba264814ffec937887424a212ab30a4a5e97f3 Mon Sep 17 00:00:00 2001 From: zoffunjunior381-jpg Date: Wed, 29 Jul 2026 17:43:37 +0100 Subject: [PATCH] fix(backend): harden request-id/sandbox middleware and limit user response fields - Validate X-Request-ID against a safe alphanumeric+hyphen charset before echoing/logging it, falling back to randomUUID() to prevent log injection - Add regression test confirming sandbox mode cannot be flipped via header when globally disabled - Add explicit Prisma select clause to GET /v1/users/:publicKey limiting the response to the public user shape - Document the database seed script and its fixtures in the backend README Closes #1016 Closes #1017 Closes #1018 Closes #1019 --- backend/README.md | 19 +++++++ backend/src/controllers/user.controller.ts | 59 ++++++++++++++++++---- backend/src/middleware/requestId.ts | 15 ++++-- backend/tests/requestId.test.ts | 17 +++++++ backend/tests/sandbox.middleware.test.ts | 15 ++++++ backend/tests/user.controller.test.ts | 18 +++++++ 6 files changed, 129 insertions(+), 14 deletions(-) diff --git a/backend/README.md b/backend/README.md index 22e1fd6e..ff6eda52 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,8 +29,27 @@ API_BASE_URL=https://api.staging.flowfi.io We use Prisma as our ORM to interact with PostgreSQL. - Schema is located at `prisma/schema.prisma`. +- Configuration (schema path, migrations path, datasource URL) is defined in `prisma.config.ts`. - Run `npx prisma studio` to view the database through a web UI. +### Seeding the database + +`prisma/seed.ts` populates the database with demo fixtures for local development. Run it with: + +```bash +npm run prisma:seed +``` + +(this runs `prisma db seed`, which in turn runs `tsx prisma/seed.ts` as configured under the `prisma.seed` key in `package.json`.) + +The script is idempotent (it uses `upsert`/fixed IDs), so it's safe to run multiple times. It creates: + +- Two demo users, keyed by fixed Stellar testnet public keys — a sender (`GCM5WPR4DDR24FSAX5LIEM4J7AI3KOWJYANSXEPKYXCSZOTAYXE75AFN`) and a recipient (`GBJCHUKZMTFSLOMNC7P4TS4VJJBTCYL3XKSOLXAUJSD56C4LHND5TWUC`). +- One demo `Stream` (`streamId: 101`) between those two users, using a fixed demo token address, with a sample rate/deposit amount and `isActive: true`. +- One demo `StreamEvent` (`eventType: 'CREATED'`) attached to that stream, with sample transaction hash, ledger sequence, and metadata. + +These fixtures are intended purely for local development/demo purposes so the frontend has data to render out of the box; they are not used in automated tests. + ## /v1 API All REST API endpoints are prefixed with `/v1`. Refer to the API Documentation in the root `README.md` and the `docs/` folder for versioning and authentication details. diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index d47d3bc0..fb8c43e4 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -5,6 +5,54 @@ import { registerUserSchema } from '../validators/user.validator.js'; import type { AuthenticatedRequest } from '../types/auth.types.js'; import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE } from '../routes/v1/events.routes.js'; +/** + * Public shape of a Stream, used when embedding streams inside a public + * user response. Excludes nothing sensitive today, but is kept explicit + * so newly added internal-only fields on the Stream model are not + * leaked automatically. + */ +const publicStreamSelect = { + id: true, + streamId: true, + sender: true, + recipient: true, + tokenAddress: true, + ratePerSecond: true, + depositedAmount: true, + withdrawnAmount: true, + startTime: true, + lastUpdateTime: true, + endTime: true, + isActive: true, + isPaused: true, + pausedAt: true, + totalPausedDuration: true, + createdAt: true, + updatedAt: true, +} as const; + +/** + * Public shape of a User. Limits the response to fields that are safe to + * expose to any caller, so internal-only fields added to the User model + * later are excluded by default rather than leaked automatically. + */ +const publicUserSelect = { + id: true, + publicKey: true, + createdAt: true, + updatedAt: true, + sentStreams: { + take: 10, + orderBy: { createdAt: 'desc' as const }, + select: publicStreamSelect, + }, + receivedStreams: { + take: 10, + orderBy: { createdAt: 'desc' as const }, + select: publicStreamSelect, + }, +}; + /** * Register a new wallet public key */ @@ -49,16 +97,7 @@ export const getUser = async (req: Request, res: Response, next: NextFunction) = const user = await prisma.user.findUnique({ where: { publicKey }, - include: { - sentStreams: { - take: 10, - orderBy: { createdAt: 'desc' } - }, - receivedStreams: { - take: 10, - orderBy: { createdAt: 'desc' } - } - } + select: publicUserSelect }); if (!user) { diff --git a/backend/src/middleware/requestId.ts b/backend/src/middleware/requestId.ts index 446046de..c95f5650 100644 --- a/backend/src/middleware/requestId.ts +++ b/backend/src/middleware/requestId.ts @@ -4,12 +4,19 @@ import logger, { requestContext } from '../logger.js'; const MAX_REQUEST_ID_LENGTH = 128; +// Only alphanumeric characters and hyphens are allowed. This prevents log +// injection via newlines or other control characters in a client-supplied +// X-Request-ID header, since the value is echoed back in responses and +// written into every log line for the request. +const SAFE_REQUEST_ID_PATTERN = /^[a-zA-Z0-9-]+$/; + +function isValidRequestId(value: string): boolean { + return value.length > 0 && value.length <= MAX_REQUEST_ID_LENGTH && SAFE_REQUEST_ID_PATTERN.test(value); +} + export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { const header = req.headers['x-request-id']; - const requestId = - typeof header === 'string' && header.length > 0 && header.length <= MAX_REQUEST_ID_LENGTH - ? header - : randomUUID(); + const requestId = typeof header === 'string' && isValidRequestId(header) ? header : randomUUID(); res.setHeader('X-Request-ID', requestId); diff --git a/backend/tests/requestId.test.ts b/backend/tests/requestId.test.ts index 35c93de6..1667a1ec 100644 --- a/backend/tests/requestId.test.ts +++ b/backend/tests/requestId.test.ts @@ -40,4 +40,21 @@ describe('RequestId Middleware', () => { const call = (res.setHeader as any).mock.calls[0]; expect(call[1]).not.toBe('a'.repeat(129)); }); + + it('should reject a header containing a newline and generate a safe id instead', () => { + const malicious = 'abc123\nfake log line injected'; + req.headers = { 'x-request-id': malicious }; + requestIdMiddleware(req as Request, res as Response, next); + const call = (res.setHeader as any).mock.calls[0]; + expect(call[1]).not.toBe(malicious); + expect(call[1]).not.toMatch(/\n/); + expect(call[1]).toMatch(/^[a-f0-9-]+$/i); + }); + + it('should reject a header with other control/special characters', () => { + req.headers = { 'x-request-id': 'id\r\nSet-Cookie: evil=1' }; + requestIdMiddleware(req as Request, res as Response, next); + const call = (res.setHeader as any).mock.calls[0]; + expect(call[1]).toMatch(/^[a-f0-9-]+$/i); + }); }); diff --git a/backend/tests/sandbox.middleware.test.ts b/backend/tests/sandbox.middleware.test.ts index cc0b3c3c..630343ae 100644 --- a/backend/tests/sandbox.middleware.test.ts +++ b/backend/tests/sandbox.middleware.test.ts @@ -32,6 +32,21 @@ describe('Sandbox Middleware', () => { expect(next).toHaveBeenCalled(); }); + it('should not let a client-supplied X-Sandbox-Mode header flip sandbox mode when globally disabled', () => { + (sandboxConfig.getSandboxConfig as any).mockReturnValue({ + enabled: false, + allowHeader: true, + allowQueryParam: true, + headerName: 'X-Sandbox-Mode', + queryParamName: 'sandbox', + }); + req.headers = { 'x-sandbox-mode': 'true' }; + sandboxMiddleware(req as SandboxRequest, res as Response, next); + expect(req.sandbox).toBe(false); + expect(req.sandboxMode).toBe(false); + expect(next).toHaveBeenCalled(); + }); + it('should detect sandbox via header', () => { (sandboxConfig.getSandboxConfig as any).mockReturnValue({ enabled: true, diff --git a/backend/tests/user.controller.test.ts b/backend/tests/user.controller.test.ts index 797eb0ab..6439d07b 100644 --- a/backend/tests/user.controller.test.ts +++ b/backend/tests/user.controller.test.ts @@ -113,6 +113,24 @@ describe('User Controller', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(mockUser); }); + + it('should query with an explicit select clause excluding internal-only fields', async () => { + const publicKey = 'GD2XP6FNWL6IWULVMPNA2RV2T7GLCJHK3RH75GBCY7TSVIWDITJN4FXJ'; + req.params = { publicKey }; + const mockUser = { id: 'uuid-1', publicKey, createdAt: new Date(), updatedAt: new Date(), sentStreams: [], receivedStreams: [] }; + (prisma.user.findUnique as any).mockResolvedValue(mockUser); + + await getUser(req as Request, res as Response, next); + + const call = (prisma.user.findUnique as any).mock.calls[0][0]; + expect(call.select).toBeDefined(); + expect(call.include).toBeUndefined(); + + const jsonArg = (res.json as any).mock.calls[0][0]; + expect(jsonArg).not.toHaveProperty('passwordHash'); + expect(jsonArg).not.toHaveProperty('internalNotes'); + expect(jsonArg).not.toHaveProperty('apiSecret'); + }); }); describe('getUserEvents', () => {