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