From 1a7250467e1c45ab6f8eba8b15498475409853f1 Mon Sep 17 00:00:00 2001 From: sirily Date: Wed, 11 Mar 2026 16:28:56 +0300 Subject: [PATCH] fix: harden web runtime and follow-up auth/db security fixes (#21) ## Summary - harden the web runtime with JSON body limits, stricter generation input validation, rate limiting, and trusted Origin/Referer checks for cookie-authenticated mutations - redact password-reset tokens from debug email transport logs and fail closed for unsupported email providers - scope generation idempotency keys per user with a Prisma migration and regression coverage ## Testing - docker build -f infra/docker/web.Dockerfile -t nroxy-web-check . - docker run --rm --entrypoint sh nroxy-web-check -lc "pnpm --filter @nproxy/providers test && pnpm --filter @nproxy/db test && pnpm --filter @nproxy/web test" Closes #14 Closes #7 Closes #8 Co-authored-by: sirily Reviewed-on: http://git.shararam.party/sirily/nroxy/pulls/21 --- apps/web/src/http-error.ts | 9 + apps/web/src/http-security.test.ts | 118 ++++++++++ apps/web/src/http-security.ts | 192 +++++++++++++++ apps/web/src/main.ts | 219 ++++++------------ apps/web/src/request-parsing.test.ts | 127 ++++++++++ apps/web/src/request-parsing.ts | 184 +++++++++++++++ docs/architecture/system-overview.md | 5 + docs/ops/deployment.md | 2 + .../migration.sql | 4 + packages/db/prisma/schema.prisma | 3 +- packages/db/src/generation-store.test.ts | 193 +++++++++++---- packages/providers/package.json | 4 +- packages/providers/src/email.test.ts | 43 ++++ packages/providers/src/email.ts | 23 +- 14 files changed, 924 insertions(+), 202 deletions(-) create mode 100644 apps/web/src/http-error.ts create mode 100644 apps/web/src/http-security.test.ts create mode 100644 apps/web/src/http-security.ts create mode 100644 apps/web/src/request-parsing.test.ts create mode 100644 apps/web/src/request-parsing.ts create mode 100644 packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql create mode 100644 packages/providers/src/email.test.ts diff --git a/apps/web/src/http-error.ts b/apps/web/src/http-error.ts new file mode 100644 index 0000000..63543aa --- /dev/null +++ b/apps/web/src/http-error.ts @@ -0,0 +1,9 @@ +export class HttpError extends Error { + constructor( + readonly statusCode: number, + readonly code: string, + message: string, + ) { + super(message); + } +} diff --git a/apps/web/src/http-security.test.ts b/apps/web/src/http-security.test.ts new file mode 100644 index 0000000..0aa3d80 --- /dev/null +++ b/apps/web/src/http-security.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import type { IncomingMessage } from "node:http"; +import test from "node:test"; +import { HttpError } from "./http-error.js"; +import { + InMemoryRateLimiter, + assertTrustedOrigin, + getRateLimitClientIp, + readJsonBody, +} from "./http-security.js"; + +test("readJsonBody parses JSON payloads within the configured limit", async () => { + const request = createMockRequest({ + headers: { + "content-length": "17", + }, + chunks: ['{"hello":"world"}'], + }); + + const body = await readJsonBody(request, { maxBytes: 64 }); + + assert.deepEqual(body, { + hello: "world", + }); +}); + +test("readJsonBody rejects payloads larger than the configured limit", async () => { + const request = createMockRequest({ + headers: { + "content-length": "128", + }, + chunks: ['{"hello":"world"}'], + }); + + await assert.rejects( + readJsonBody(request, { maxBytes: 32 }), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 413 && + error.code === "payload_too_large", + ); +}); + +test("InMemoryRateLimiter blocks after the configured request budget is exhausted", () => { + const limiter = new InMemoryRateLimiter(); + const policy = { + id: "auth", + windowMs: 60_000, + maxRequests: 2, + }; + + assert.deepEqual(limiter.consume(policy, "ip:127.0.0.1", 1_000), { allowed: true }); + assert.deepEqual(limiter.consume(policy, "ip:127.0.0.1", 2_000), { allowed: true }); + + const blocked = limiter.consume(policy, "ip:127.0.0.1", 3_000); + + assert.equal(blocked.allowed, false); + assert.equal(blocked.retryAfterSeconds, 58); +}); + +test("InMemoryRateLimiter drops expired buckets during periodic sweeps", () => { + const limiter = new InMemoryRateLimiter(); + const policy = { + id: "auth", + windowMs: 1_000, + maxRequests: 1, + }; + + assert.deepEqual(limiter.consume(policy, "stale", 1_000), { allowed: true }); + + for (let index = 0; index < 99; index += 1) { + limiter.consume(policy, `fresh-${index}`, 3_000); + } + + assert.deepEqual(limiter.consume(policy, "stale", 3_000), { allowed: true }); +}); + +test("assertTrustedOrigin accepts configured origins and requires browser origin metadata", () => { + const request = createMockRequest({ + headers: { + origin: "https://app.nproxy.test", + "x-forwarded-for": "198.51.100.7, 198.51.100.8", + }, + }); + + assert.doesNotThrow(() => + assertTrustedOrigin(request, ["https://app.nproxy.test", "https://admin.nproxy.test"]), + ); + assert.equal(getRateLimitClientIp(request), "198.51.100.7"); + + assert.throws( + () => assertTrustedOrigin(createMockRequest(), ["https://app.nproxy.test"]), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 403 && + error.code === "csrf_origin_required", + ); +}); + +function createMockRequest(input?: { + headers?: IncomingMessage["headers"]; + chunks?: Array; + remoteAddress?: string; +}): IncomingMessage { + const chunks = input?.chunks ?? []; + + return { + headers: input?.headers ?? {}, + socket: { + remoteAddress: input?.remoteAddress ?? "127.0.0.1", + }, + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield typeof chunk === "string" ? Buffer.from(chunk) : chunk; + } + }, + } as IncomingMessage; +} diff --git a/apps/web/src/http-security.ts b/apps/web/src/http-security.ts new file mode 100644 index 0000000..b944488 --- /dev/null +++ b/apps/web/src/http-security.ts @@ -0,0 +1,192 @@ +import type { IncomingMessage } from "node:http"; +import { HttpError } from "./http-error.js"; + +export interface JsonBodyOptions { + maxBytes: number; +} + +export interface RateLimitPolicy { + id: string; + windowMs: number; + maxRequests: number; +} + +export interface RateLimitDecision { + allowed: boolean; + retryAfterSeconds?: number; +} + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +export async function readJsonBody( + request: IncomingMessage, + options: JsonBodyOptions, +): Promise { + const declaredLength = readContentLength(request); + + if (declaredLength !== null && declaredLength > options.maxBytes) { + throw new HttpError( + 413, + "payload_too_large", + `Request body must not exceed ${options.maxBytes} bytes.`, + ); + } + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + for await (const chunk of request) { + const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk; + totalBytes += buffer.byteLength; + + if (totalBytes > options.maxBytes) { + throw new HttpError( + 413, + "payload_too_large", + `Request body must not exceed ${options.maxBytes} bytes.`, + ); + } + + chunks.push(buffer); + } + + if (chunks.length === 0) { + throw new HttpError(400, "invalid_json", "Request body must not be empty."); + } + + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + } catch { + throw new HttpError(400, "invalid_json", "Request body must be valid JSON."); + } +} + +export class InMemoryRateLimiter { + private readonly entries = new Map(); + private sweepCounter = 0; + + consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision { + this.sweepCounter += 1; + + if (this.sweepCounter % 100 === 0) { + this.deleteExpiredEntries(now); + } + + const bucketKey = `${policy.id}:${key}`; + const existing = this.entries.get(bucketKey); + + if (!existing || existing.resetAt <= now) { + this.entries.set(bucketKey, { + count: 1, + resetAt: now + policy.windowMs, + }); + return { allowed: true }; + } + + if (existing.count >= policy.maxRequests) { + return { + allowed: false, + retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)), + }; + } + + existing.count += 1; + this.entries.set(bucketKey, existing); + return { allowed: true }; + } + + private deleteExpiredEntries(now: number): void { + for (const [bucketKey, entry] of this.entries.entries()) { + if (entry.resetAt <= now) { + this.entries.delete(bucketKey); + } + } + } +} + +export function getRateLimitClientIp(request: IncomingMessage): string { + const forwardedFor = request.headers["x-forwarded-for"]; + const headerValue = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + + if (headerValue) { + const firstIp = headerValue + .split(",") + .map((part) => part.trim()) + .find((part) => part.length > 0); + + if (firstIp) { + return firstIp; + } + } + + return request.socket.remoteAddress ?? "unknown"; +} + +export function assertTrustedOrigin( + request: IncomingMessage, + allowedOrigins: readonly string[], +): void { + const candidate = readRequestOrigin(request); + + if (!candidate) { + throw new HttpError( + 403, + "csrf_origin_required", + "State-changing requests must include an Origin or Referer header.", + ); + } + + if (!allowedOrigins.includes(candidate)) { + throw new HttpError( + 403, + "csrf_origin_invalid", + "State-changing requests must originate from an allowed nproxy origin.", + ); + } +} + +function readRequestOrigin(request: IncomingMessage): string | null { + const originHeader = request.headers.origin; + const rawOrigin = Array.isArray(originHeader) ? originHeader[0] : originHeader; + + if (rawOrigin) { + try { + return new URL(rawOrigin).origin; + } catch { + throw new HttpError(400, "invalid_origin", "Origin header must be a valid URL."); + } + } + + const refererHeader = request.headers.referer; + const rawReferer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader; + + if (!rawReferer) { + return null; + } + + try { + return new URL(rawReferer).origin; + } catch { + throw new HttpError(400, "invalid_referer", "Referer header must be a valid URL."); + } +} + +function readContentLength(request: IncomingMessage): number | null { + const contentLength = request.headers["content-length"]; + const rawValue = Array.isArray(contentLength) ? contentLength[0] : contentLength; + + if (!rawValue) { + return null; + } + + const parsed = Number.parseInt(rawValue, 10); + + if (!Number.isFinite(parsed) || parsed < 0) { + throw new HttpError(400, "invalid_content_length", "Content-Length must be valid."); + } + + return parsed; +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 69aefe3..ab22908 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -17,9 +17,23 @@ import { AuthError, GenerationRequestError, createGenerationRequest, - type CreateGenerationRequestInput, } from "@nproxy/domain"; import { serializePublicAccountOverview } from "./account-response.js"; +import { HttpError } from "./http-error.js"; +import { + InMemoryRateLimiter, + assertTrustedOrigin, + getRateLimitClientIp, + readJsonBody, + type RateLimitPolicy, +} from "./http-security.js"; +import { + defaultGenerationRequestConstraints, + mapCreateGenerationRequestInput, + readAuthPayload, + readEmailOnlyPayload, + readPasswordResetConfirmPayload, +} from "./request-parsing.js"; const config = loadConfig(); const port = Number.parseInt(process.env.PORT ?? "3000", 10); @@ -30,6 +44,23 @@ const generationStore = createPrismaGenerationStore(prisma); const emailTransport = createEmailTransport(config.email); const paymentProviderAdapter = createPaymentProviderAdapter(config.payment); const sessionCookieName = "nproxy_session"; +const maxJsonBodyBytes = 16 * 1024; +const rateLimiter = new InMemoryRateLimiter(); +const mutationAllowedOrigins = [config.urls.appBaseUrl.origin, config.urls.adminBaseUrl.origin]; +const authRateLimitPolicy: RateLimitPolicy = { + id: "auth", + windowMs: 60_000, + maxRequests: 10, +}; +const generationRateLimitPolicy: RateLimitPolicy = { + id: "generation", + windowMs: 60_000, + maxRequests: 30, +}; +const generationRequestConstraints = { + ...defaultGenerationRequestConstraints, + providerModels: [config.provider.nanoBananaDefaultModel], +}; const server = createServer(async (request, response) => { try { @@ -39,7 +70,9 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/register") { - const body = await readJsonBody(request); + assertTrustedOrigin(request, mutationAllowedOrigins); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); const session = await authStore.registerUser({ email: payload.email, @@ -57,7 +90,9 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/login") { - const body = await readJsonBody(request); + assertTrustedOrigin(request, mutationAllowedOrigins); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); const session = await authStore.loginUser({ email: payload.email, @@ -75,7 +110,9 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/request") { - const body = await readJsonBody(request); + assertTrustedOrigin(request, mutationAllowedOrigins); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readEmailOnlyPayload(body); const challenge = await authStore.createPasswordResetChallenge({ email: payload.email, @@ -103,7 +140,9 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") { - const body = await readJsonBody(request); + assertTrustedOrigin(request, mutationAllowedOrigins); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readPasswordResetConfirmPayload(body); await authStore.resetPassword({ @@ -120,6 +159,7 @@ const server = createServer(async (request, response) => { if (request.method === "POST" && request.url === "/api/auth/logout") { const authenticatedSession = await requireAuthenticatedSession(request); + assertTrustedOrigin(request, mutationAllowedOrigins); await authStore.revokeSession(authenticatedSession.token); @@ -176,6 +216,7 @@ const server = createServer(async (request, response) => { if (request.method === "POST" && request.url === "/api/billing/invoices") { const authenticatedSession = await requireAuthenticatedSession(request); + assertTrustedOrigin(request, mutationAllowedOrigins); const invoice = await billingStore.createSubscriptionInvoice({ userId: authenticatedSession.user.id, paymentProvider: config.payment.provider, @@ -189,6 +230,7 @@ const server = createServer(async (request, response) => { if (request.method === "POST" && request.url === "/api/auth/logout-all") { const authenticatedSession = await requireAuthenticatedSession(request); + assertTrustedOrigin(request, mutationAllowedOrigins); const revokedCount = await authStore.revokeAllUserSessions({ userId: authenticatedSession.user.id, exceptSessionId: authenticatedSession.session.id, @@ -206,6 +248,7 @@ const server = createServer(async (request, response) => { if (invoiceMarkPaidMatch) { const authenticatedSession = await requireAuthenticatedSession(request); + assertTrustedOrigin(request, mutationAllowedOrigins); if (!authenticatedSession.user.isAdmin) { throw new HttpError(403, "forbidden", "Admin session is required."); @@ -231,6 +274,7 @@ const server = createServer(async (request, response) => { if (sessionMatch) { const authenticatedSession = await requireAuthenticatedSession(request); + assertTrustedOrigin(request, mutationAllowedOrigins); const sessionId = decodeURIComponent(sessionMatch[1] ?? ""); if (sessionId === authenticatedSession.session.id) { @@ -261,8 +305,18 @@ const server = createServer(async (request, response) => { if (request.method === "POST" && request.url === "/api/generations") { const authenticatedSession = await requireAuthenticatedSession(request); - const body = await readJsonBody(request); - const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id); + assertTrustedOrigin(request, mutationAllowedOrigins); + enforceRateLimit( + request, + generationRateLimitPolicy, + `user:${authenticatedSession.user.id}:${getRateLimitClientIp(request)}`, + ); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); + const requestInput = mapCreateGenerationRequestInput( + body, + authenticatedSession.user.id, + generationRequestConstraints, + ); const result = await createGenerationRequest(generationStore, requestInput); sendJson(response, result.reusedExistingRequest ? 200 : 201, { @@ -340,133 +394,6 @@ process.once("SIGINT", async () => { process.exit(0); }); -async function readJsonBody( - request: IncomingMessage, -): Promise { - const chunks: Uint8Array[] = []; - - for await (const chunk of request) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); - } - - if (chunks.length === 0) { - throw new HttpError(400, "invalid_json", "Request body must not be empty."); - } - - const rawBody = Buffer.concat(chunks).toString("utf8"); - - try { - return JSON.parse(rawBody) as unknown; - } catch { - throw new HttpError(400, "invalid_json", "Request body must be valid JSON."); - } -} - -function readAuthPayload(body: unknown): { email: string; password: string } { - if (!body || typeof body !== "object") { - throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); - } - - const payload = body as Record; - - return { - email: readRequiredString(payload.email, "email"), - password: readRequiredString(payload.password, "password"), - }; -} - -function readEmailOnlyPayload(body: unknown): { email: string } { - if (!body || typeof body !== "object") { - throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); - } - - const payload = body as Record; - - return { - email: readRequiredString(payload.email, "email"), - }; -} - -function readPasswordResetConfirmPayload( - body: unknown, -): { token: string; password: string } { - if (!body || typeof body !== "object") { - throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); - } - - const payload = body as Record; - - return { - token: readRequiredString(payload.token, "token"), - password: readRequiredString(payload.password, "password"), - }; -} - -function mapCreateGenerationRequestInput( - body: unknown, - userId: string, -): CreateGenerationRequestInput { - if (!body || typeof body !== "object") { - throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); - } - - const payload = body as Record; - - return { - userId, - mode: readGenerationMode(payload.mode), - providerModel: readRequiredString(payload.providerModel, "providerModel"), - prompt: readRequiredString(payload.prompt, "prompt"), - resolutionPreset: readRequiredString(payload.resolutionPreset, "resolutionPreset"), - batchSize: readRequiredInteger(payload.batchSize, "batchSize"), - ...(payload.sourceImageKey !== undefined - ? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") } - : {}), - ...(payload.imageStrength !== undefined - ? { imageStrength: readRequiredNumber(payload.imageStrength, "imageStrength") } - : {}), - ...(payload.idempotencyKey !== undefined - ? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") } - : {}), - }; -} - -function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] { - if (value === "text_to_image" || value === "image_to_image") { - return value; - } - - throw new HttpError( - 400, - "invalid_mode", - 'mode must be "text_to_image" or "image_to_image".', - ); -} - -function readRequiredString(value: unknown, field: string): string { - if (typeof value !== "string" || value.trim().length === 0) { - throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`); - } - - return value; -} - -function readRequiredInteger(value: unknown, field: string): number { - if (typeof value !== "number" || !Number.isInteger(value)) { - throw new HttpError(400, "invalid_field", `${field} must be an integer.`); - } - - return value; -} - -function readRequiredNumber(value: unknown, field: string): number { - if (typeof value !== "number" || Number.isNaN(value)) { - throw new HttpError(400, "invalid_field", `${field} must be a number.`); - } - - return value; -} - function serializeAuthenticatedUser(user: { id: string; email: string; @@ -631,7 +558,7 @@ function createSessionCookie(token: string, expiresAt: Date): string { `${sessionCookieName}=${encodeURIComponent(token)}`, "Path=/", "HttpOnly", - "SameSite=Lax", + "SameSite=Strict", "Secure", `Expires=${expiresAt.toUTCString()}`, ].join("; "); @@ -642,7 +569,7 @@ function clearSessionCookie(): string { `${sessionCookieName}=`, "Path=/", "HttpOnly", - "SameSite=Lax", + "SameSite=Strict", "Secure", "Expires=Thu, 01 Jan 1970 00:00:00 GMT", ].join("; "); @@ -739,12 +666,18 @@ function sendJson( response.end(JSON.stringify(payload)); } -class HttpError extends Error { - constructor( - readonly statusCode: number, - readonly code: string, - message: string, - ) { - super(message); +function enforceRateLimit( + request: IncomingMessage, + policy: RateLimitPolicy, + key = getRateLimitClientIp(request), +): void { + const result = rateLimiter.consume(policy, key); + + if (!result.allowed) { + throw new HttpError( + 429, + "rate_limited", + `Retry after ${result.retryAfterSeconds ?? 1} seconds.`, + ); } } diff --git a/apps/web/src/request-parsing.test.ts b/apps/web/src/request-parsing.test.ts new file mode 100644 index 0000000..621f41e --- /dev/null +++ b/apps/web/src/request-parsing.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { HttpError } from "./http-error.js"; +import { + defaultGenerationRequestConstraints, + mapCreateGenerationRequestInput, +} from "./request-parsing.js"; + +const generationRequestConstraints = { + ...defaultGenerationRequestConstraints, + providerModels: ["nano-banana"], +}; + +test("mapCreateGenerationRequestInput accepts supported generation options", () => { + const parsed = mapCreateGenerationRequestInput( + { + mode: "image_to_image", + providerModel: "nano-banana", + prompt: "sharpen this", + resolutionPreset: "1024", + batchSize: 2, + sourceImageKey: "uploads/source.png", + imageStrength: 0.4, + idempotencyKey: "req-1", + }, + "user_1", + generationRequestConstraints, + ); + + assert.deepEqual(parsed, { + userId: "user_1", + mode: "image_to_image", + providerModel: "nano-banana", + prompt: "sharpen this", + resolutionPreset: "1024", + batchSize: 2, + sourceImageKey: "uploads/source.png", + imageStrength: 0.4, + idempotencyKey: "req-1", + }); +}); + +test("mapCreateGenerationRequestInput rejects unsupported provider models", () => { + assert.throws( + () => + mapCreateGenerationRequestInput( + { + mode: "text_to_image", + providerModel: "other-model", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + }, + "user_1", + generationRequestConstraints, + ), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 400 && + error.message === "providerModel must be one of: nano-banana.", + ); +}); + +test("mapCreateGenerationRequestInput rejects unsupported resolution presets", () => { + assert.throws( + () => + mapCreateGenerationRequestInput( + { + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "2048", + batchSize: 1, + }, + "user_1", + generationRequestConstraints, + ), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 400 && + error.message === "resolutionPreset must be one of: 1024.", + ); +}); + +test("mapCreateGenerationRequestInput rejects batch sizes outside the allowed range", () => { + assert.throws( + () => + mapCreateGenerationRequestInput( + { + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 5, + }, + "user_1", + generationRequestConstraints, + ), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 400 && + error.message === "batchSize must be between 1 and 4.", + ); +}); + +test("mapCreateGenerationRequestInput rejects image strengths outside the supported range", () => { + assert.throws( + () => + mapCreateGenerationRequestInput( + { + mode: "image_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + sourceImageKey: "uploads/source.png", + imageStrength: 1.5, + }, + "user_1", + generationRequestConstraints, + ), + (error: unknown) => + error instanceof HttpError && + error.statusCode === 400 && + error.message === "imageStrength must be between 0 and 1.", + ); +}); diff --git a/apps/web/src/request-parsing.ts b/apps/web/src/request-parsing.ts new file mode 100644 index 0000000..4552c88 --- /dev/null +++ b/apps/web/src/request-parsing.ts @@ -0,0 +1,184 @@ +import type { CreateGenerationRequestInput } from "@nproxy/domain"; +import { HttpError } from "./http-error.js"; + +export interface GenerationRequestConstraints { + providerModels: readonly string[]; + resolutionPresets: readonly string[]; + minBatchSize: number; + maxBatchSize: number; + minImageStrength: number; + maxImageStrength: number; +} + +export const defaultGenerationRequestConstraints: Omit< + GenerationRequestConstraints, + "providerModels" +> = { + resolutionPresets: ["1024"], + minBatchSize: 1, + maxBatchSize: 4, + minImageStrength: 0, + maxImageStrength: 1, +}; + +export function readAuthPayload(body: unknown): { email: string; password: string } { + const payload = readObjectBody(body); + + return { + email: readRequiredString(payload.email, "email"), + password: readRequiredString(payload.password, "password"), + }; +} + +export function readEmailOnlyPayload(body: unknown): { email: string } { + const payload = readObjectBody(body); + + return { + email: readRequiredString(payload.email, "email"), + }; +} + +export function readPasswordResetConfirmPayload( + body: unknown, +): { token: string; password: string } { + const payload = readObjectBody(body); + + return { + token: readRequiredString(payload.token, "token"), + password: readRequiredString(payload.password, "password"), + }; +} + +export function mapCreateGenerationRequestInput( + body: unknown, + userId: string, + constraints: GenerationRequestConstraints, +): CreateGenerationRequestInput { + const payload = readObjectBody(body); + + return { + userId, + mode: readGenerationMode(payload.mode), + providerModel: readSupportedString( + payload.providerModel, + "providerModel", + constraints.providerModels, + ), + prompt: readRequiredString(payload.prompt, "prompt"), + resolutionPreset: readSupportedString( + payload.resolutionPreset, + "resolutionPreset", + constraints.resolutionPresets, + ), + batchSize: readBoundedInteger( + payload.batchSize, + "batchSize", + constraints.minBatchSize, + constraints.maxBatchSize, + ), + ...(payload.sourceImageKey !== undefined + ? { sourceImageKey: readRequiredString(payload.sourceImageKey, "sourceImageKey") } + : {}), + ...(payload.imageStrength !== undefined + ? { + imageStrength: readBoundedNumber( + payload.imageStrength, + "imageStrength", + constraints.minImageStrength, + constraints.maxImageStrength, + ), + } + : {}), + ...(payload.idempotencyKey !== undefined + ? { idempotencyKey: readRequiredString(payload.idempotencyKey, "idempotencyKey") } + : {}), + }; +} + +function readObjectBody(body: unknown): Record { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new HttpError(400, "invalid_body", "Request body must be a JSON object."); + } + + return body as Record; +} + +function readGenerationMode(value: unknown): CreateGenerationRequestInput["mode"] { + if (value === "text_to_image" || value === "image_to_image") { + return value; + } + + throw new HttpError( + 400, + "invalid_mode", + 'mode must be "text_to_image" or "image_to_image".', + ); +} + +function readRequiredString(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new HttpError(400, "invalid_field", `${field} must be a non-empty string.`); + } + + return value; +} + +function readSupportedString( + value: unknown, + field: string, + supportedValues: readonly string[], +): string { + const parsed = readRequiredString(value, field); + + if (!supportedValues.includes(parsed)) { + throw new HttpError( + 400, + "invalid_field", + `${field} must be one of: ${supportedValues.join(", ")}.`, + ); + } + + return parsed; +} + +function readBoundedInteger( + value: unknown, + field: string, + min: number, + max: number, +): number { + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new HttpError(400, "invalid_field", `${field} must be an integer.`); + } + + if (value < min || value > max) { + throw new HttpError( + 400, + "invalid_field", + `${field} must be between ${min} and ${max}.`, + ); + } + + return value; +} + +function readBoundedNumber( + value: unknown, + field: string, + min: number, + max: number, +): number { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new HttpError(400, "invalid_field", `${field} must be a number.`); + } + + if (value < min || value > max) { + throw new HttpError( + 400, + "invalid_field", + `${field} must be between ${min} and ${max}.`, + ); + } + + return value; +} diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 28f59ee..a8e6ad8 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -32,3 +32,8 @@ - User-caused provider failures are terminal for that request. - Balance or quota exhaustion removes a key from active rotation. - Provider-key state transitions must be audited. + +## Web session posture +- Browser sessions use `Secure`, `HttpOnly`, `SameSite=Strict` cookies. +- State-changing cookie-authenticated endpoints accept requests only from the configured app/admin origins and require browser `Origin` or `Referer` metadata. +- The current API posture assumes a same-origin browser client. If cross-site embeds or third-party POST flows are introduced later, add an explicit CSRF token mechanism instead of relaxing the cookie/origin checks. diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 09ce314..a2029ec 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -23,6 +23,8 @@ Deploy on one VPS with Docker Compose. - Keep secrets in server-side environment files or a secret manager. - Back up PostgreSQL and object storage separately. - Prefer Telegram long polling to avoid an extra public webhook surface for the bot. +- In non-production environments, set `EMAIL_PROVIDER=example` only when you explicitly want the built-in debug transport. It logs redacted email previews and must never emit live password-reset tokens. +- Do not rely on implicit email fallbacks. Unsupported providers now fail fast at startup so misconfigured deployments do not silently drop password-reset or billing mail. ## Upgrade strategy - Build new images. diff --git a/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql b/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql new file mode 100644 index 0000000..12d202c --- /dev/null +++ b/packages/db/prisma/migrations/20260311160000_scope_generation_idempotency_per_user/migration.sql @@ -0,0 +1,4 @@ +DROP INDEX "GenerationRequest_idempotencyKey_key"; + +CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key" +ON "GenerationRequest"("userId", "idempotencyKey"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 3e9432e..ba6b7cc 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -187,7 +187,7 @@ model GenerationRequest { resolutionPreset String batchSize Int imageStrength Decimal? @db.Decimal(4, 3) - idempotencyKey String? @unique + idempotencyKey String? terminalErrorCode String? terminalErrorText String? requestedAt DateTime @default(now()) @@ -200,6 +200,7 @@ model GenerationRequest { assets GeneratedAsset[] usageLedgerEntry UsageLedgerEntry? + @@unique([userId, idempotencyKey]) @@index([userId, status, requestedAt]) } diff --git a/packages/db/src/generation-store.test.ts b/packages/db/src/generation-store.test.ts index 45230b8..631e4ad 100644 --- a/packages/db/src/generation-store.test.ts +++ b/packages/db/src/generation-store.test.ts @@ -6,10 +6,12 @@ import { createPrismaGenerationStore } from "./generation-store.js"; test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => { const database = createGenerationDatabase({ - subscription: createSubscriptionFixture({ - status: "active", - currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), - }), + subscriptions: [ + createSubscriptionFixture({ + status: "active", + currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"), + }), + ], }); const store = createPrismaGenerationStore(database.client); @@ -29,11 +31,67 @@ test("createGenerationRequest rejects an expired active subscription and marks i assert.equal(database.calls.subscriptionUpdateMany.length, 1); assert.equal(database.calls.generationRequestCreate.length, 0); - assert.equal(database.state.subscription?.status, "expired"); + assert.equal(database.state.subscriptions[0]?.status, "expired"); +}); + +test("createGenerationRequest scopes idempotency-key reuse per user", async () => { + const database = createGenerationDatabase({ + subscriptions: [ + createSubscriptionFixture({ + userId: "user_1", + status: "active", + currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"), + }), + createSubscriptionFixture({ + id: "subscription_2", + userId: "user_2", + status: "active", + currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"), + }), + ], + generationRequests: [ + createGenerationRequestFixture({ + id: "request_existing", + userId: "user_1", + idempotencyKey: "shared-key", + }), + ], + }); + const store = createPrismaGenerationStore(database.client); + + const reused = await createGenerationRequest(store, { + userId: "user_1", + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + idempotencyKey: "shared-key", + }); + + assert.equal(reused.reusedExistingRequest, true); + assert.equal(reused.request.id, "request_existing"); + + const created = await createGenerationRequest(store, { + userId: "user_2", + mode: "text_to_image", + providerModel: "nano-banana", + prompt: "hello", + resolutionPreset: "1024", + batchSize: 1, + idempotencyKey: "shared-key", + }); + + assert.equal(created.reusedExistingRequest, false); + assert.equal(created.request.userId, "user_2"); + assert.equal(created.request.idempotencyKey, "shared-key"); + assert.equal(database.calls.generationRequestCreate.length, 1); + assert.equal(database.state.generationRequests.length, 2); }); function createGenerationDatabase(input: { - subscription: ReturnType | null; + subscriptions: Array>; + generationRequests?: Array>; }) { const calls = { subscriptionUpdateMany: [] as Array>, @@ -41,7 +99,8 @@ function createGenerationDatabase(input: { }; const state = { - subscription: input.subscription, + subscriptions: [...input.subscriptions], + generationRequests: [...(input.generationRequests ?? [])], }; const client = { @@ -51,15 +110,19 @@ function createGenerationDatabase(input: { }: { where: { userId: string; status?: "active" }; }) => { - if (!state.subscription || state.subscription.userId !== where.userId) { - return null; - } + return ( + state.subscriptions.find((subscription) => { + if (subscription.userId !== where.userId) { + return false; + } - if (where.status && state.subscription.status !== where.status) { - return null; - } + if (where.status && subscription.status !== where.status) { + return false; + } - return state.subscription; + return true; + }) ?? null + ); }, updateMany: async ({ where, @@ -70,19 +133,21 @@ function createGenerationDatabase(input: { }) => { calls.subscriptionUpdateMany.push({ where, data }); - if ( - state.subscription && - state.subscription.id === where.id && - state.subscription.status === where.status - ) { - state.subscription = { - ...state.subscription, + let updatedCount = 0; + + state.subscriptions = state.subscriptions.map((subscription) => { + if (subscription.id !== where.id || subscription.status !== where.status) { + return subscription; + } + + updatedCount += 1; + return { + ...subscription, status: data.status, }; - return { count: 1 }; - } + }); - return { count: 0 }; + return { count: updatedCount }; }, }, usageLedgerEntry: { @@ -95,28 +160,37 @@ function createGenerationDatabase(input: { generationRequest: { create: async ({ data }: { data: Record }) => { calls.generationRequestCreate.push({ data }); - return { - id: "request_1", + const request = createGenerationRequestFixture({ + id: `request_${state.generationRequests.length + 1}`, userId: data.userId as string, mode: data.mode as string, - status: "queued", providerModel: data.providerModel as string, prompt: data.prompt as string, - sourceImageKey: null, resolutionPreset: data.resolutionPreset as string, batchSize: data.batchSize as number, - imageStrength: null, - idempotencyKey: null, - terminalErrorCode: null, - terminalErrorText: null, - requestedAt: new Date("2026-03-10T12:00:00.000Z"), - startedAt: null, - completedAt: null, - createdAt: new Date("2026-03-10T12:00:00.000Z"), - updatedAt: new Date("2026-03-10T12:00:00.000Z"), - }; + ...(data.sourceImageKey !== undefined + ? { sourceImageKey: data.sourceImageKey as string } + : {}), + ...(data.imageStrength !== undefined + ? { imageStrength: data.imageStrength as Prisma.Decimal } + : {}), + ...(data.idempotencyKey !== undefined + ? { idempotencyKey: data.idempotencyKey as string } + : {}), + }); + state.generationRequests.push(request); + return request; }, - findFirst: async () => null, + findFirst: async ({ + where, + }: { + where: { userId: string; idempotencyKey: string }; + }) => + state.generationRequests.find( + (request) => + request.userId === where.userId && + request.idempotencyKey === where.idempotencyKey, + ) ?? null, }, } as unknown as Parameters[0]; @@ -128,12 +202,14 @@ function createGenerationDatabase(input: { } function createSubscriptionFixture(input: { + id?: string; + userId?: string; status: "active" | "expired" | "past_due"; currentPeriodEnd: Date; }) { return { - id: "subscription_1", - userId: "user_1", + id: input.id ?? "subscription_1", + userId: input.userId ?? "user_1", planId: "plan_1", status: input.status, renewsManually: true, @@ -156,3 +232,38 @@ function createSubscriptionFixture(input: { }, }; } + +function createGenerationRequestFixture(input: { + id: string; + userId: string; + mode?: string; + status?: string; + providerModel?: string; + prompt?: string; + sourceImageKey?: string; + resolutionPreset?: string; + batchSize?: number; + imageStrength?: Prisma.Decimal; + idempotencyKey?: string; +}) { + return { + id: input.id, + userId: input.userId, + mode: input.mode ?? "text_to_image", + status: input.status ?? "queued", + providerModel: input.providerModel ?? "nano-banana", + prompt: input.prompt ?? "hello", + sourceImageKey: input.sourceImageKey ?? null, + resolutionPreset: input.resolutionPreset ?? "1024", + batchSize: input.batchSize ?? 1, + imageStrength: input.imageStrength ?? null, + idempotencyKey: input.idempotencyKey ?? null, + terminalErrorCode: null, + terminalErrorText: null, + requestedAt: new Date("2026-03-10T12:00:00.000Z"), + startedAt: null, + completedAt: null, + createdAt: new Date("2026-03-10T12:00:00.000Z"), + updatedAt: new Date("2026-03-10T12:00:00.000Z"), + }; +} diff --git a/packages/providers/package.json b/packages/providers/package.json index 81e46dc..d78d5fe 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -16,7 +16,9 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "check": "tsc -p tsconfig.json --noEmit" + "check": "tsc -p tsconfig.json --noEmit", + "pretest": "pnpm build", + "test": "node --test dist/**/*.test.js" }, "dependencies": { "@nproxy/domain": "workspace:*" diff --git a/packages/providers/src/email.test.ts b/packages/providers/src/email.test.ts new file mode 100644 index 0000000..4033fe0 --- /dev/null +++ b/packages/providers/src/email.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createEmailTransport } from "./email.js"; + +test("example email transport redacts password-reset tokens before logging", async () => { + const transport = createEmailTransport({ + provider: "example", + from: "noreply@nproxy.test", + apiKey: "unused", + }); + const logMessages: string[] = []; + const originalConsoleLog = console.log; + console.log = (message?: unknown) => { + logMessages.push(String(message ?? "")); + }; + + try { + await transport.send({ + to: "user@example.com", + subject: "Reset your password", + text: "Reset link: https://app.nproxy.test/reset-password?token=secret-token-123", + }); + } finally { + console.log = originalConsoleLog; + } + + assert.equal(logMessages.length, 1); + assert.match(logMessages[0] ?? "", /"mode":"debug_redacted"/); + assert.match(logMessages[0] ?? "", /token=\[REDACTED\]/); + assert.doesNotMatch(logMessages[0] ?? "", /secret-token-123/); +}); + +test("unsupported email providers fail closed", () => { + assert.throws( + () => + createEmailTransport({ + provider: "smtp", + from: "noreply@nproxy.test", + apiKey: "unused", + }), + /Unsupported email provider: smtp/, + ); +}); diff --git a/packages/providers/src/email.ts b/packages/providers/src/email.ts index a5b12e2..21615e2 100644 --- a/packages/providers/src/email.ts +++ b/packages/providers/src/email.ts @@ -20,29 +20,20 @@ export function createEmailTransport(config: { JSON.stringify({ service: "email", provider: config.provider, + mode: "debug_redacted", from: config.from, to: input.to, subject: input.subject, - text: input.text, + textPreview: redactEmailText(input.text), }), ); }, }; } - return { - async send(input) { - console.log( - JSON.stringify({ - service: "email", - provider: config.provider, - mode: "noop_fallback", - from: config.from, - to: input.to, - subject: input.subject, - text: input.text, - }), - ); - }, - }; + throw new Error(`Unsupported email provider: ${config.provider}`); +} + +function redactEmailText(text: string): string { + return text.replace(/([?&]token=)[^&\s]+/gi, "$1[REDACTED]"); }