fix: harden web runtime and follow-up auth/db security fixes #21
9
apps/web/src/http-error.ts
Normal file
9
apps/web/src/http-error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
101
apps/web/src/http-security.test.ts
Normal file
101
apps/web/src/http-security.test.ts
Normal 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;
|
||||
}
|
||||
177
apps/web/src/http-security.ts
Normal file
177
apps/web/src/http-security.ts
Normal 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;
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
apps/web/src/request-parsing.test.ts
Normal file
127
apps/web/src/request-parsing.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
184
apps/web/src/request-parsing.ts
Normal file
184
apps/web/src/request-parsing.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user