fix: harden web runtime and follow-up auth/db security fixes #21

Merged
sirily merged 4 commits from fix/api-runtime-security-controls into master 2026-03-11 16:28:56 +03:00
7 changed files with 675 additions and 143 deletions
Showing only changes of commit 24e3aa0bc0 - Show all commits

View File

@@ -0,0 +1,9 @@
export class HttpError extends Error {
constructor(
readonly statusCode: number,
readonly code: string,
message: string,
) {
super(message);
}
}

View File

@@ -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<string | Uint8Array>;
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;
}

View File

@@ -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<unknown> {
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<string, RateLimitEntry>();
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;
}

View File

@@ -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<unknown> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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.`,
);
}
}

View File

@@ -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.",
);
});

View File

@@ -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<string, unknown> {
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<string, unknown>;
}
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;
}

View File

@@ -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.