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..7f23828 --- /dev/null +++ b/apps/web/src/http-security.test.ts @@ -0,0 +1,101 @@ +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("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..7489deb --- /dev/null +++ b/apps/web/src/http-security.ts @@ -0,0 +1,177 @@ +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(); + + consume(policy: RateLimitPolicy, key: string, now = Date.now()): RateLimitDecision { + 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 }; + } +} + +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..f4ad34a 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,8 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/register") { - const body = await readJsonBody(request); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); const session = await authStore.registerUser({ email: payload.email, @@ -57,7 +89,8 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/login") { - const body = await readJsonBody(request); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readAuthPayload(body); const session = await authStore.loginUser({ email: payload.email, @@ -75,7 +108,8 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/request") { - const body = await readJsonBody(request); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readEmailOnlyPayload(body); const challenge = await authStore.createPasswordResetChallenge({ email: payload.email, @@ -103,7 +137,8 @@ const server = createServer(async (request, response) => { } if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") { - const body = await readJsonBody(request); + enforceRateLimit(request, authRateLimitPolicy); + const body = await readJsonBody(request, { maxBytes: maxJsonBodyBytes }); const payload = readPasswordResetConfirmPayload(body); await authStore.resetPassword({ @@ -120,6 +155,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 +212,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 +226,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 +244,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 +270,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 +301,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 +390,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 +554,7 @@ function createSessionCookie(token: string, expiresAt: Date): string { `${sessionCookieName}=${encodeURIComponent(token)}`, "Path=/", "HttpOnly", - "SameSite=Lax", + "SameSite=Strict", "Secure", `Expires=${expiresAt.toUTCString()}`, ].join("; "); @@ -642,7 +565,7 @@ function clearSessionCookie(): string { `${sessionCookieName}=`, "Path=/", "HttpOnly", - "SameSite=Lax", + "SameSite=Strict", "Secure", "Expires=Thu, 01 Jan 1970 00:00:00 GMT", ].join("; "); @@ -739,12 +662,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.