feat: add zod-based validation for webhook and form payloads

This commit is contained in:
Codex
2026-02-18 13:15:13 +00:00
parent 81d5e7faf5
commit 6b1f9cddbc
3 changed files with 146 additions and 28 deletions

View File

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

49
src/lib/validation.js Normal file
View File

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

56
test/validation.test.js Normal file
View File

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