diff --git a/src/app.js b/src/app.js index d2836e3..2f545fc 100644 --- a/src/app.js +++ b/src/app.js @@ -24,6 +24,14 @@ const { clearUserCookie, } = require("./lib/auth"); const { FixedWindowRateLimiter } = require("./lib/rate-limit"); +const { + XWebhookPayloadSchema, + PolarWebhookPayloadSchema, + LoginFormSchema, + TopUpFormSchema, + SimulateMentionFormSchema, + parseOrThrow, +} = require("./lib/validation"); function sanitizeReturnTo(value, fallback = "/app") { if (!value || typeof value !== "string") { @@ -41,14 +49,6 @@ function sanitizeReturnTo(value, fallback = "/app") { return value; } -function parsePositiveInt(raw, fallback = null) { - const parsed = Number.parseInt(String(raw || ""), 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - return fallback; - } - return parsed; -} - function buildApp({ config, initialState = null, onMutation = null }) { const engine = new XArtAudioEngine({ creditConfig: config.credit, @@ -135,7 +135,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { return json(401, { error: "invalid_signature" }); } - const payload = parseJSON(rawBody); + const payload = parseOrThrow( + XWebhookPayloadSchema, + parseJSON(rawBody), + "invalid_x_webhook_payload", + ); try { const result = engine.processMention({ @@ -177,7 +181,11 @@ function buildApp({ config, initialState = null, onMutation = null }) { return json(401, { error: "invalid_signature" }); } - const payload = parseJSON(rawBody); + const payload = parseOrThrow( + PolarWebhookPayloadSchema, + parseJSON(rawBody), + "invalid_polar_webhook_payload", + ); try { engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`); @@ -244,19 +252,18 @@ function buildApp({ config, initialState = null, onMutation = null }) { } const form = parseFormUrlEncoded(rawBody); - const requestedUserId = String(form.userId || "").trim(); - - if (!/^[a-zA-Z0-9_-]{2,40}$/.test(requestedUserId)) { + try { + const login = parseOrThrow(LoginFormSchema, form); + const nextPath = sanitizeReturnTo(login.returnTo, "/app"); + return redirect(nextPath, { + "set-cookie": serializeUserCookie(login.userId), + }); + } catch (error) { return redirect(withQuery("/login", { returnTo: sanitizeReturnTo(form.returnTo, "/app"), - error: "Username must be 2-40 characters using letters, numbers, _ or -", + error: error.message, })); } - - const nextPath = sanitizeReturnTo(form.returnTo, "/app"); - return redirect(nextPath, { - "set-cookie": serializeUserCookie(requestedUserId), - }); } if (method === "POST" && path === "/auth/logout") { @@ -291,8 +298,10 @@ function buildApp({ config, initialState = null, onMutation = null }) { } const form = parseFormUrlEncoded(rawBody); - const amount = parsePositiveInt(form.amount, null); - if (!amount || amount > 500) { + let amount; + try { + amount = parseOrThrow(TopUpFormSchema, form, "Invalid credit amount").amount; + } catch { return redirect(withQuery("/app", { flash: "Invalid credit amount" })); } @@ -313,11 +322,15 @@ function buildApp({ config, initialState = null, onMutation = null }) { } const form = parseFormUrlEncoded(rawBody); - const title = String(form.title || "").trim(); - const body = String(form.body || "").trim(); - - if (!title || !body) { - return redirect(withQuery("/app", { flash: "Title and body are required" })); + let mentionInput; + try { + mentionInput = parseOrThrow( + SimulateMentionFormSchema, + form, + "Title and body are required", + ); + } catch (error) { + return redirect(withQuery("/app", { flash: error.message })); } try { @@ -329,8 +342,8 @@ function buildApp({ config, initialState = null, onMutation = null }) { authorId: userId, article: { id: `manual-article:${randomUUID()}`, - title, - body, + title: mentionInput.title, + body: mentionInput.body, }, }, }); diff --git a/src/lib/validation.js b/src/lib/validation.js new file mode 100644 index 0000000..7dca980 --- /dev/null +++ b/src/lib/validation.js @@ -0,0 +1,49 @@ +"use strict"; + +const { z } = require("zod"); + +const usernameRegex = /^[a-zA-Z0-9_-]{2,40}$/; + +const XWebhookPayloadSchema = z.object({ + mentionPostId: z.string().min(1), + callerUserId: z.string().min(1), + parentPost: z.record(z.string(), z.unknown()).or(z.object({}).passthrough()), +}); + +const PolarWebhookPayloadSchema = z.object({ + userId: z.string().min(1), + credits: z.coerce.number().int().positive(), + eventId: z.string().min(1), +}); + +const LoginFormSchema = z.object({ + userId: z.string().regex(usernameRegex, "Username must be 2-40 characters using letters, numbers, _ or -"), + returnTo: z.string().optional(), +}); + +const TopUpFormSchema = z.object({ + amount: z.coerce.number().int().positive().max(500), +}); + +const SimulateMentionFormSchema = z.object({ + title: z.string().trim().min(1).max(200), + body: z.string().trim().min(1).max(120000), +}); + +function parseOrThrow(schema, payload, errorMessage) { + const result = schema.safeParse(payload); + if (!result.success) { + const message = errorMessage || result.error.issues[0].message || "validation_failed"; + throw new Error(message); + } + return result.data; +} + +module.exports = { + XWebhookPayloadSchema, + PolarWebhookPayloadSchema, + LoginFormSchema, + TopUpFormSchema, + SimulateMentionFormSchema, + parseOrThrow, +}; diff --git a/test/validation.test.js b/test/validation.test.js new file mode 100644 index 0000000..1556c8e --- /dev/null +++ b/test/validation.test.js @@ -0,0 +1,56 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + XWebhookPayloadSchema, + PolarWebhookPayloadSchema, + LoginFormSchema, + TopUpFormSchema, + SimulateMentionFormSchema, + parseOrThrow, +} = require("../src/lib/validation"); + +test("validates X webhook payload", () => { + const parsed = parseOrThrow(XWebhookPayloadSchema, { + mentionPostId: "m1", + callerUserId: "u1", + parentPost: { id: "p1", article: { body: "x" } }, + }); + + assert.equal(parsed.mentionPostId, "m1"); +}); + +test("validates Polar webhook payload with numeric coercion", () => { + const parsed = parseOrThrow(PolarWebhookPayloadSchema, { + userId: "u1", + credits: "12", + eventId: "evt-1", + }); + + assert.equal(parsed.credits, 12); +}); + +test("rejects invalid login username", () => { + assert.throws( + () => parseOrThrow(LoginFormSchema, { userId: "!" }), + /Username must be 2-40 characters using letters, numbers, _ or -/, + ); +}); + +test("validates topup amount range", () => { + assert.throws(() => parseOrThrow(TopUpFormSchema, { amount: "999" }), /Too big/); + + const parsed = parseOrThrow(TopUpFormSchema, { amount: "20" }); + assert.equal(parsed.amount, 20); +}); + +test("validates simulate mention form", () => { + const parsed = parseOrThrow(SimulateMentionFormSchema, { + title: "Hello", + body: "World", + }); + + assert.equal(parsed.title, "Hello"); + assert.equal(parsed.body, "World"); +});