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

## Summary
- harden the web runtime with JSON body limits, stricter generation input validation, rate limiting, and trusted Origin/Referer checks for cookie-authenticated mutations
- redact password-reset tokens from debug email transport logs and fail closed for unsupported email providers
- scope generation idempotency keys per user with a Prisma migration and regression coverage

## Testing
- docker build -f infra/docker/web.Dockerfile -t nroxy-web-check .
- docker run --rm --entrypoint sh nroxy-web-check -lc "pnpm --filter @nproxy/providers test && pnpm --filter @nproxy/db test && pnpm --filter @nproxy/web test"

Closes #14
Closes #7
Closes #8

Co-authored-by: sirily <sirily@git.shararam.party>
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-03-11 16:28:56 +03:00
parent 9641678fa3
commit 1a7250467e
14 changed files with 924 additions and 202 deletions

View File

@@ -0,0 +1,4 @@
DROP INDEX "GenerationRequest_idempotencyKey_key";
CREATE UNIQUE INDEX "GenerationRequest_userId_idempotencyKey_key"
ON "GenerationRequest"("userId", "idempotencyKey");

View File

@@ -187,7 +187,7 @@ model GenerationRequest {
resolutionPreset String
batchSize Int
imageStrength Decimal? @db.Decimal(4, 3)
idempotencyKey String? @unique
idempotencyKey String?
terminalErrorCode String?
terminalErrorText String?
requestedAt DateTime @default(now())
@@ -200,6 +200,7 @@ model GenerationRequest {
assets GeneratedAsset[]
usageLedgerEntry UsageLedgerEntry?
@@unique([userId, idempotencyKey])
@@index([userId, status, requestedAt])
}

View File

@@ -6,10 +6,12 @@ import { createPrismaGenerationStore } from "./generation-store.js";
test("createGenerationRequest rejects an expired active subscription and marks it expired", async () => {
const database = createGenerationDatabase({
subscription: createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
subscriptions: [
createSubscriptionFixture({
status: "active",
currentPeriodEnd: new Date("2026-03-10T11:59:59.000Z"),
}),
],
});
const store = createPrismaGenerationStore(database.client);
@@ -29,11 +31,67 @@ test("createGenerationRequest rejects an expired active subscription and marks i
assert.equal(database.calls.subscriptionUpdateMany.length, 1);
assert.equal(database.calls.generationRequestCreate.length, 0);
assert.equal(database.state.subscription?.status, "expired");
assert.equal(database.state.subscriptions[0]?.status, "expired");
});
test("createGenerationRequest scopes idempotency-key reuse per user", async () => {
const database = createGenerationDatabase({
subscriptions: [
createSubscriptionFixture({
userId: "user_1",
status: "active",
currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"),
}),
createSubscriptionFixture({
id: "subscription_2",
userId: "user_2",
status: "active",
currentPeriodEnd: new Date("2026-04-10T11:59:59.000Z"),
}),
],
generationRequests: [
createGenerationRequestFixture({
id: "request_existing",
userId: "user_1",
idempotencyKey: "shared-key",
}),
],
});
const store = createPrismaGenerationStore(database.client);
const reused = await createGenerationRequest(store, {
userId: "user_1",
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
idempotencyKey: "shared-key",
});
assert.equal(reused.reusedExistingRequest, true);
assert.equal(reused.request.id, "request_existing");
const created = await createGenerationRequest(store, {
userId: "user_2",
mode: "text_to_image",
providerModel: "nano-banana",
prompt: "hello",
resolutionPreset: "1024",
batchSize: 1,
idempotencyKey: "shared-key",
});
assert.equal(created.reusedExistingRequest, false);
assert.equal(created.request.userId, "user_2");
assert.equal(created.request.idempotencyKey, "shared-key");
assert.equal(database.calls.generationRequestCreate.length, 1);
assert.equal(database.state.generationRequests.length, 2);
});
function createGenerationDatabase(input: {
subscription: ReturnType<typeof createSubscriptionFixture> | null;
subscriptions: Array<ReturnType<typeof createSubscriptionFixture>>;
generationRequests?: Array<ReturnType<typeof createGenerationRequestFixture>>;
}) {
const calls = {
subscriptionUpdateMany: [] as Array<Record<string, unknown>>,
@@ -41,7 +99,8 @@ function createGenerationDatabase(input: {
};
const state = {
subscription: input.subscription,
subscriptions: [...input.subscriptions],
generationRequests: [...(input.generationRequests ?? [])],
};
const client = {
@@ -51,15 +110,19 @@ function createGenerationDatabase(input: {
}: {
where: { userId: string; status?: "active" };
}) => {
if (!state.subscription || state.subscription.userId !== where.userId) {
return null;
}
return (
state.subscriptions.find((subscription) => {
if (subscription.userId !== where.userId) {
return false;
}
if (where.status && state.subscription.status !== where.status) {
return null;
}
if (where.status && subscription.status !== where.status) {
return false;
}
return state.subscription;
return true;
}) ?? null
);
},
updateMany: async ({
where,
@@ -70,19 +133,21 @@ function createGenerationDatabase(input: {
}) => {
calls.subscriptionUpdateMany.push({ where, data });
if (
state.subscription &&
state.subscription.id === where.id &&
state.subscription.status === where.status
) {
state.subscription = {
...state.subscription,
let updatedCount = 0;
state.subscriptions = state.subscriptions.map((subscription) => {
if (subscription.id !== where.id || subscription.status !== where.status) {
return subscription;
}
updatedCount += 1;
return {
...subscription,
status: data.status,
};
return { count: 1 };
}
});
return { count: 0 };
return { count: updatedCount };
},
},
usageLedgerEntry: {
@@ -95,28 +160,37 @@ function createGenerationDatabase(input: {
generationRequest: {
create: async ({ data }: { data: Record<string, unknown> }) => {
calls.generationRequestCreate.push({ data });
return {
id: "request_1",
const request = createGenerationRequestFixture({
id: `request_${state.generationRequests.length + 1}`,
userId: data.userId as string,
mode: data.mode as string,
status: "queued",
providerModel: data.providerModel as string,
prompt: data.prompt as string,
sourceImageKey: null,
resolutionPreset: data.resolutionPreset as string,
batchSize: data.batchSize as number,
imageStrength: null,
idempotencyKey: null,
terminalErrorCode: null,
terminalErrorText: null,
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
startedAt: null,
completedAt: null,
createdAt: new Date("2026-03-10T12:00:00.000Z"),
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
};
...(data.sourceImageKey !== undefined
? { sourceImageKey: data.sourceImageKey as string }
: {}),
...(data.imageStrength !== undefined
? { imageStrength: data.imageStrength as Prisma.Decimal }
: {}),
...(data.idempotencyKey !== undefined
? { idempotencyKey: data.idempotencyKey as string }
: {}),
});
state.generationRequests.push(request);
return request;
},
findFirst: async () => null,
findFirst: async ({
where,
}: {
where: { userId: string; idempotencyKey: string };
}) =>
state.generationRequests.find(
(request) =>
request.userId === where.userId &&
request.idempotencyKey === where.idempotencyKey,
) ?? null,
},
} as unknown as Parameters<typeof createPrismaGenerationStore>[0];
@@ -128,12 +202,14 @@ function createGenerationDatabase(input: {
}
function createSubscriptionFixture(input: {
id?: string;
userId?: string;
status: "active" | "expired" | "past_due";
currentPeriodEnd: Date;
}) {
return {
id: "subscription_1",
userId: "user_1",
id: input.id ?? "subscription_1",
userId: input.userId ?? "user_1",
planId: "plan_1",
status: input.status,
renewsManually: true,
@@ -156,3 +232,38 @@ function createSubscriptionFixture(input: {
},
};
}
function createGenerationRequestFixture(input: {
id: string;
userId: string;
mode?: string;
status?: string;
providerModel?: string;
prompt?: string;
sourceImageKey?: string;
resolutionPreset?: string;
batchSize?: number;
imageStrength?: Prisma.Decimal;
idempotencyKey?: string;
}) {
return {
id: input.id,
userId: input.userId,
mode: input.mode ?? "text_to_image",
status: input.status ?? "queued",
providerModel: input.providerModel ?? "nano-banana",
prompt: input.prompt ?? "hello",
sourceImageKey: input.sourceImageKey ?? null,
resolutionPreset: input.resolutionPreset ?? "1024",
batchSize: input.batchSize ?? 1,
imageStrength: input.imageStrength ?? null,
idempotencyKey: input.idempotencyKey ?? null,
terminalErrorCode: null,
terminalErrorText: null,
requestedAt: new Date("2026-03-10T12:00:00.000Z"),
startedAt: null,
completedAt: null,
createdAt: new Date("2026-03-10T12:00:00.000Z"),
updatedAt: new Date("2026-03-10T12:00:00.000Z"),
};
}

View File

@@ -16,7 +16,9 @@
],
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit"
"check": "tsc -p tsconfig.json --noEmit",
"pretest": "pnpm build",
"test": "node --test dist/**/*.test.js"
},
"dependencies": {
"@nproxy/domain": "workspace:*"

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createEmailTransport } from "./email.js";
test("example email transport redacts password-reset tokens before logging", async () => {
const transport = createEmailTransport({
provider: "example",
from: "noreply@nproxy.test",
apiKey: "unused",
});
const logMessages: string[] = [];
const originalConsoleLog = console.log;
console.log = (message?: unknown) => {
logMessages.push(String(message ?? ""));
};
try {
await transport.send({
to: "user@example.com",
subject: "Reset your password",
text: "Reset link: https://app.nproxy.test/reset-password?token=secret-token-123",
});
} finally {
console.log = originalConsoleLog;
}
assert.equal(logMessages.length, 1);
assert.match(logMessages[0] ?? "", /"mode":"debug_redacted"/);
assert.match(logMessages[0] ?? "", /token=\[REDACTED\]/);
assert.doesNotMatch(logMessages[0] ?? "", /secret-token-123/);
});
test("unsupported email providers fail closed", () => {
assert.throws(
() =>
createEmailTransport({
provider: "smtp",
from: "noreply@nproxy.test",
apiKey: "unused",
}),
/Unsupported email provider: smtp/,
);
});

View File

@@ -20,29 +20,20 @@ export function createEmailTransport(config: {
JSON.stringify({
service: "email",
provider: config.provider,
mode: "debug_redacted",
from: config.from,
to: input.to,
subject: input.subject,
text: input.text,
textPreview: redactEmailText(input.text),
}),
);
},
};
}
return {
async send(input) {
console.log(
JSON.stringify({
service: "email",
provider: config.provider,
mode: "noop_fallback",
from: config.from,
to: input.to,
subject: input.subject,
text: input.text,
}),
);
},
};
throw new Error(`Unsupported email provider: ${config.provider}`);
}
function redactEmailText(text: string): string {
return text.replace(/([?&]token=)[^&\s]+/gi, "$1[REDACTED]");
}