fix: harden web API runtime controls
This commit is contained in:
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,
|
AuthError,
|
||||||
GenerationRequestError,
|
GenerationRequestError,
|
||||||
createGenerationRequest,
|
createGenerationRequest,
|
||||||
type CreateGenerationRequestInput,
|
|
||||||
} from "@nproxy/domain";
|
} from "@nproxy/domain";
|
||||||
import { serializePublicAccountOverview } from "./account-response.js";
|
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 config = loadConfig();
|
||||||
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
|
||||||
@@ -30,6 +44,23 @@ const generationStore = createPrismaGenerationStore(prisma);
|
|||||||
const emailTransport = createEmailTransport(config.email);
|
const emailTransport = createEmailTransport(config.email);
|
||||||
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
const paymentProviderAdapter = createPaymentProviderAdapter(config.payment);
|
||||||
const sessionCookieName = "nproxy_session";
|
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) => {
|
const server = createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +70,8 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/register") {
|
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 payload = readAuthPayload(body);
|
||||||
const session = await authStore.registerUser({
|
const session = await authStore.registerUser({
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
@@ -57,7 +89,8 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/login") {
|
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 payload = readAuthPayload(body);
|
||||||
const session = await authStore.loginUser({
|
const session = await authStore.loginUser({
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
@@ -75,7 +108,8 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/request") {
|
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 payload = readEmailOnlyPayload(body);
|
||||||
const challenge = await authStore.createPasswordResetChallenge({
|
const challenge = await authStore.createPasswordResetChallenge({
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
@@ -103,7 +137,8 @@ const server = createServer(async (request, response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/password-reset/confirm") {
|
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);
|
const payload = readPasswordResetConfirmPayload(body);
|
||||||
|
|
||||||
await authStore.resetPassword({
|
await authStore.resetPassword({
|
||||||
@@ -120,6 +155,7 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/logout") {
|
if (request.method === "POST" && request.url === "/api/auth/logout") {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
|
|
||||||
await authStore.revokeSession(authenticatedSession.token);
|
await authStore.revokeSession(authenticatedSession.token);
|
||||||
|
|
||||||
@@ -176,6 +212,7 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/billing/invoices") {
|
if (request.method === "POST" && request.url === "/api/billing/invoices") {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
const invoice = await billingStore.createSubscriptionInvoice({
|
const invoice = await billingStore.createSubscriptionInvoice({
|
||||||
userId: authenticatedSession.user.id,
|
userId: authenticatedSession.user.id,
|
||||||
paymentProvider: config.payment.provider,
|
paymentProvider: config.payment.provider,
|
||||||
@@ -189,6 +226,7 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/auth/logout-all") {
|
if (request.method === "POST" && request.url === "/api/auth/logout-all") {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
const revokedCount = await authStore.revokeAllUserSessions({
|
const revokedCount = await authStore.revokeAllUserSessions({
|
||||||
userId: authenticatedSession.user.id,
|
userId: authenticatedSession.user.id,
|
||||||
exceptSessionId: authenticatedSession.session.id,
|
exceptSessionId: authenticatedSession.session.id,
|
||||||
@@ -206,6 +244,7 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (invoiceMarkPaidMatch) {
|
if (invoiceMarkPaidMatch) {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
|
|
||||||
if (!authenticatedSession.user.isAdmin) {
|
if (!authenticatedSession.user.isAdmin) {
|
||||||
throw new HttpError(403, "forbidden", "Admin session is required.");
|
throw new HttpError(403, "forbidden", "Admin session is required.");
|
||||||
@@ -231,6 +270,7 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
|
const sessionId = decodeURIComponent(sessionMatch[1] ?? "");
|
||||||
|
|
||||||
if (sessionId === authenticatedSession.session.id) {
|
if (sessionId === authenticatedSession.session.id) {
|
||||||
@@ -261,8 +301,18 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
if (request.method === "POST" && request.url === "/api/generations") {
|
if (request.method === "POST" && request.url === "/api/generations") {
|
||||||
const authenticatedSession = await requireAuthenticatedSession(request);
|
const authenticatedSession = await requireAuthenticatedSession(request);
|
||||||
const body = await readJsonBody(request);
|
assertTrustedOrigin(request, mutationAllowedOrigins);
|
||||||
const requestInput = mapCreateGenerationRequestInput(body, authenticatedSession.user.id);
|
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);
|
const result = await createGenerationRequest(generationStore, requestInput);
|
||||||
|
|
||||||
sendJson(response, result.reusedExistingRequest ? 200 : 201, {
|
sendJson(response, result.reusedExistingRequest ? 200 : 201, {
|
||||||
@@ -340,133 +390,6 @@ process.once("SIGINT", async () => {
|
|||||||
process.exit(0);
|
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: {
|
function serializeAuthenticatedUser(user: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -631,7 +554,7 @@ function createSessionCookie(token: string, expiresAt: Date): string {
|
|||||||
`${sessionCookieName}=${encodeURIComponent(token)}`,
|
`${sessionCookieName}=${encodeURIComponent(token)}`,
|
||||||
"Path=/",
|
"Path=/",
|
||||||
"HttpOnly",
|
"HttpOnly",
|
||||||
"SameSite=Lax",
|
"SameSite=Strict",
|
||||||
"Secure",
|
"Secure",
|
||||||
`Expires=${expiresAt.toUTCString()}`,
|
`Expires=${expiresAt.toUTCString()}`,
|
||||||
].join("; ");
|
].join("; ");
|
||||||
@@ -642,7 +565,7 @@ function clearSessionCookie(): string {
|
|||||||
`${sessionCookieName}=`,
|
`${sessionCookieName}=`,
|
||||||
"Path=/",
|
"Path=/",
|
||||||
"HttpOnly",
|
"HttpOnly",
|
||||||
"SameSite=Lax",
|
"SameSite=Strict",
|
||||||
"Secure",
|
"Secure",
|
||||||
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
@@ -739,12 +662,18 @@ function sendJson(
|
|||||||
response.end(JSON.stringify(payload));
|
response.end(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
class HttpError extends Error {
|
function enforceRateLimit(
|
||||||
constructor(
|
request: IncomingMessage,
|
||||||
readonly statusCode: number,
|
policy: RateLimitPolicy,
|
||||||
readonly code: string,
|
key = getRateLimitClientIp(request),
|
||||||
message: string,
|
): void {
|
||||||
) {
|
const result = rateLimiter.consume(policy, key);
|
||||||
super(message);
|
|
||||||
|
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.
|
- User-caused provider failures are terminal for that request.
|
||||||
- Balance or quota exhaustion removes a key from active rotation.
|
- Balance or quota exhaustion removes a key from active rotation.
|
||||||
- Provider-key state transitions must be audited.
|
- 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