feat: add zod-based validation for webhook and form payloads
This commit is contained in:
69
src/app.js
69
src/app.js
@@ -24,6 +24,14 @@ const {
|
|||||||
clearUserCookie,
|
clearUserCookie,
|
||||||
} = require("./lib/auth");
|
} = require("./lib/auth");
|
||||||
const { FixedWindowRateLimiter } = require("./lib/rate-limit");
|
const { FixedWindowRateLimiter } = require("./lib/rate-limit");
|
||||||
|
const {
|
||||||
|
XWebhookPayloadSchema,
|
||||||
|
PolarWebhookPayloadSchema,
|
||||||
|
LoginFormSchema,
|
||||||
|
TopUpFormSchema,
|
||||||
|
SimulateMentionFormSchema,
|
||||||
|
parseOrThrow,
|
||||||
|
} = require("./lib/validation");
|
||||||
|
|
||||||
function sanitizeReturnTo(value, fallback = "/app") {
|
function sanitizeReturnTo(value, fallback = "/app") {
|
||||||
if (!value || typeof value !== "string") {
|
if (!value || typeof value !== "string") {
|
||||||
@@ -41,14 +49,6 @@ function sanitizeReturnTo(value, fallback = "/app") {
|
|||||||
return value;
|
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 }) {
|
function buildApp({ config, initialState = null, onMutation = null }) {
|
||||||
const engine = new XArtAudioEngine({
|
const engine = new XArtAudioEngine({
|
||||||
creditConfig: config.credit,
|
creditConfig: config.credit,
|
||||||
@@ -135,7 +135,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
|||||||
return json(401, { error: "invalid_signature" });
|
return json(401, { error: "invalid_signature" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJSON(rawBody);
|
const payload = parseOrThrow(
|
||||||
|
XWebhookPayloadSchema,
|
||||||
|
parseJSON(rawBody),
|
||||||
|
"invalid_x_webhook_payload",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = engine.processMention({
|
const result = engine.processMention({
|
||||||
@@ -177,7 +181,11 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
|||||||
return json(401, { error: "invalid_signature" });
|
return json(401, { error: "invalid_signature" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = parseJSON(rawBody);
|
const payload = parseOrThrow(
|
||||||
|
PolarWebhookPayloadSchema,
|
||||||
|
parseJSON(rawBody),
|
||||||
|
"invalid_polar_webhook_payload",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
engine.topUpCredits(payload.userId, payload.credits, `polar:${payload.eventId}`);
|
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 form = parseFormUrlEncoded(rawBody);
|
||||||
const requestedUserId = String(form.userId || "").trim();
|
try {
|
||||||
|
const login = parseOrThrow(LoginFormSchema, form);
|
||||||
if (!/^[a-zA-Z0-9_-]{2,40}$/.test(requestedUserId)) {
|
const nextPath = sanitizeReturnTo(login.returnTo, "/app");
|
||||||
|
return redirect(nextPath, {
|
||||||
|
"set-cookie": serializeUserCookie(login.userId),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
return redirect(withQuery("/login", {
|
return redirect(withQuery("/login", {
|
||||||
returnTo: sanitizeReturnTo(form.returnTo, "/app"),
|
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") {
|
if (method === "POST" && path === "/auth/logout") {
|
||||||
@@ -291,8 +298,10 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = parseFormUrlEncoded(rawBody);
|
const form = parseFormUrlEncoded(rawBody);
|
||||||
const amount = parsePositiveInt(form.amount, null);
|
let amount;
|
||||||
if (!amount || amount > 500) {
|
try {
|
||||||
|
amount = parseOrThrow(TopUpFormSchema, form, "Invalid credit amount").amount;
|
||||||
|
} catch {
|
||||||
return redirect(withQuery("/app", { flash: "Invalid credit amount" }));
|
return redirect(withQuery("/app", { flash: "Invalid credit amount" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,11 +322,15 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const form = parseFormUrlEncoded(rawBody);
|
const form = parseFormUrlEncoded(rawBody);
|
||||||
const title = String(form.title || "").trim();
|
let mentionInput;
|
||||||
const body = String(form.body || "").trim();
|
try {
|
||||||
|
mentionInput = parseOrThrow(
|
||||||
if (!title || !body) {
|
SimulateMentionFormSchema,
|
||||||
return redirect(withQuery("/app", { flash: "Title and body are required" }));
|
form,
|
||||||
|
"Title and body are required",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return redirect(withQuery("/app", { flash: error.message }));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -329,8 +342,8 @@ function buildApp({ config, initialState = null, onMutation = null }) {
|
|||||||
authorId: userId,
|
authorId: userId,
|
||||||
article: {
|
article: {
|
||||||
id: `manual-article:${randomUUID()}`,
|
id: `manual-article:${randomUUID()}`,
|
||||||
title,
|
title: mentionInput.title,
|
||||||
body,
|
body: mentionInput.body,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
49
src/lib/validation.js
Normal file
49
src/lib/validation.js
Normal 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
56
test/validation.test.js
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user