diff --git a/src/forms/actions.ts b/src/forms/actions.ts index 1c46524..9685ad7 100644 --- a/src/forms/actions.ts +++ b/src/forms/actions.ts @@ -7,13 +7,16 @@ import { normalizeRedditUsername, upsertRedditModerationContext } from "./reddit const discordApiBase = "https://discord.com/api/v10" -const resolveTarget = (target: FormTarget, submission: FormSubmission) => { +export const resolveTarget = (target: FormTarget, submission: FormSubmission) => { if (target === "authUser") { return submission.applicantId ?? "" } if (target === "authUsername") { return submission.applicantUsername ?? "" } + if (target === "clawhubUserId") { + return parseSubmissionPayload(submission).clawhubUserId ?? "" + } return parseSubmissionPayload(submission)[target] ?? target } @@ -79,12 +82,12 @@ const getClawHubHeaders = () => { } } -const clawHubUnbanRequest = async ( +export const clawHubUnbanRequest = async ( action: Extract, submission: FormSubmission, options: { reviewerDiscordId?: string } ) => { - const target = resolveTarget(action.target, submission) + const target = resolveTarget("clawhubUserId", submission) if (!target) { throw new Error("ClawHub user ID is missing from submission context.") } diff --git a/src/forms/payload.ts b/src/forms/payload.ts new file mode 100644 index 0000000..d0ca26a --- /dev/null +++ b/src/forms/payload.ts @@ -0,0 +1,27 @@ +import type { FormConfig, FormField } from "./types.js" + +const isCollectableField = (field: FormField) => + field.type === "text" || + field.type === "textarea" || + field.type === "select" || + field.type === "checkbox" + +export const collectPayload = async (request: Request, form: FormConfig) => { + const body = await request.formData() + const allowed = new Set(form.fields.filter(isCollectableField).map((field) => field.id)) + const payload: Record = {} + body.forEach((value, key) => { + if (key !== "session" && allowed.has(key)) { + payload[key] = String(value).trim() + } + }) + return { payload, session: String(body.get("session") ?? "") } +} + +export const buildSubmissionPayload = ( + collected: Record, + context: Record +) => ({ + ...collected, + ...context +}) diff --git a/src/forms/server.tsx b/src/forms/server.tsx index ee60436..ce2335a 100644 --- a/src/forms/server.tsx +++ b/src/forms/server.tsx @@ -37,6 +37,7 @@ import { intakeContentRightsCase } from "../clawhubContentRights/workflow.js" import { sendContentRightsReceipt } from "../clawhubContentRights/receipt.js" +import { buildSubmissionPayload, collectPayload } from "./payload.js" const discordApiBase = "https://discord.com/api/v10" const githubApiBase = "https://api.github.com" @@ -102,17 +103,6 @@ const discordDmInstallAction = () => ({ description: "Want a Discord DM when this submission is reviewed?" }) -const collectPayload = async (request: Request) => { - const body = await request.formData() - const payload: Record = {} - body.forEach((value, key) => { - if (key !== "session") { - payload[key] = String(value).trim() - } - }) - return { payload, session: String(body.get("session") ?? "") } -} - const actionLabel = (action: string) => { if (action === "banned") return "ban" if (action === "muted") return "mute" @@ -375,7 +365,7 @@ const handleFormSubmit = async (request: Request, form: FormConfig, client: Clie ) } } - const collected = await collectPayload(request) + const collected = await collectPayload(request, form) const sessionUser = isFormsDev() ? localUsers[getFormAuthProviders(form)[0] ?? "discord"] : await readSession(collected.session, form.id) const user = sessionUser && formAllowsProvider(form, sessionUser.provider as FormAuthProvider) ? sessionUser : null if (!user) { @@ -389,10 +379,7 @@ const handleFormSubmit = async (request: Request, form: FormConfig, client: Clie if (error) { return new Response(renderPage(form.title, ), { status: 400, headers: { "content-type": "text/html; charset=utf-8" } }) } - const payload = { - ...context, - ...collected.payload - } + const payload = buildSubmissionPayload(collected.payload, context) const submission = await createFormSubmission({ formId: form.id, authProvider: user.provider, diff --git a/test/forms/clawhub-unban.test.ts b/test/forms/clawhub-unban.test.ts new file mode 100644 index 0000000..097b5e7 --- /dev/null +++ b/test/forms/clawhub-unban.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { formConfigs } from "../../forms.config.js" +import type { FormSubmission } from "../../src/db/schema.js" +import { clawHubUnbanRequest, resolveTarget } from "../../src/forms/actions.js" +import { buildSubmissionPayload, collectPayload } from "../../src/forms/payload.js" + +const clawhubForm = formConfigs.find((form) => form.id === "clawhub") +if (!clawhubForm) { + throw new Error("clawhub form config is missing") +} + +const contextId = "oauth-clawhub-user" +const attackerId = "attacker" + +const context = { + action: "banned", + unaction: "unbanned", + clawhubUserId: contextId, + clawhubHandle: "@victim", + account: "Victim", + banReason: "spam", + moderationReason: "spam", + date: "2026-01-01T00:00:00.000Z", + scope: "ClawHub account", + auditAction: "ban", + auditActorUserId: "mod-1", + links: "https://clawhub.ai/victim" +} + +const attackFormData = () => { + const body = new FormData() + body.set("session", "sess") + body.set("appealReason", "please unban me") + body.set("changedSince", "I will follow the rules") + body.set("extraContext", "thanks") + body.set("clawhubUserId", attackerId) + body.set("clawhubHandle", "@attacker") + body.set("injected", "nope") + return body +} + +const attackRequest = () => + new Request("https://appeals.openclaw.ai/clawhub/submit", { + method: "POST", + body: attackFormData() + }) + +const makeSubmission = (payload: Record): FormSubmission => ({ + id: 1, + formId: "clawhub", + status: "submitted", + authProvider: "github", + applicantId: "gh-123", + applicantUsername: "victim", + payload: JSON.stringify(payload), + reviewChannelId: "1", + reviewMessageId: null, + reviewThreadId: null, + decidedAt: null, + decidedById: null, + decisionReason: null, + actionResult: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z" +}) + +const storedAppealPayload = async () => { + const collected = await collectPayload(attackRequest(), clawhubForm) + return buildSubmissionPayload(collected.payload, context) +} + +describe("ClawHub appeal submit payload", () => { + test("collectPayload keeps configured fields and drops client clawhubUserId", async () => { + const collected = await collectPayload(attackRequest(), clawhubForm) + expect(collected.session).toBe("sess") + expect(collected.payload.appealReason).toBe("please unban me") + expect(collected.payload.changedSince).toBe("I will follow the rules") + expect(collected.payload.extraContext).toBe("thanks") + expect(collected.payload.clawhubUserId).toBeUndefined() + expect(collected.payload.clawhubHandle).toBeUndefined() + expect(collected.payload.injected).toBeUndefined() + }) + + test("stored payload keeps OAuth context identity over injected FormData", async () => { + const payload = await storedAppealPayload() + expect(payload.clawhubUserId).toBe(contextId) + expect(payload.clawhubHandle).toBe("@victim") + expect(payload.appealReason).toBe("please unban me") + expect(payload.injected).toBeUndefined() + }) + + test("buildSubmissionPayload overlays context last for identity fields", () => { + const payload = buildSubmissionPayload( + { + appealReason: "please unban me", + clawhubUserId: attackerId, + clawhubHandle: "@attacker" + }, + { + clawhubUserId: contextId, + clawhubHandle: "@victim" + } + ) + expect(payload.clawhubUserId).toBe(contextId) + expect(payload.clawhubHandle).toBe("@victim") + expect(payload.appealReason).toBe("please unban me") + }) +}) + +describe("ClawHub unban target", () => { + const originalFetch = globalThis.fetch + const originalToken = process.env.CLAWHUB_BAN_APPEALS_TOKEN + + afterEach(() => { + globalThis.fetch = originalFetch + if (originalToken === undefined) { + delete process.env.CLAWHUB_BAN_APPEALS_TOKEN + } else { + process.env.CLAWHUB_BAN_APPEALS_TOKEN = originalToken + } + }) + + test("resolveTarget uses context clawhubUserId not the injected id", async () => { + const payload = await storedAppealPayload() + expect(resolveTarget("clawhubUserId", makeSubmission(payload))).toBe(contextId) + }) + + test("clawHubUnbanRequest posts the context id not attacker FormData", async () => { + process.env.CLAWHUB_BAN_APPEALS_TOKEN = "test-token" + const posted: Array<{ url: string; body: { userId?: string } }> = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + posted.push({ + url: String(input), + body: JSON.parse(String(init?.body ?? "{}")) as { userId?: string } + }) + return new Response("{}", { status: 200 }) + }) as typeof fetch + + await clawHubUnbanRequest( + { type: "clawhub.unbanUser", target: "clawhubUserId", reason: "Appeal accepted." }, + makeSubmission(await storedAppealPayload()), + { reviewerDiscordId: "reviewer-1" } + ) + + expect(posted).toHaveLength(1) + expect(posted[0]?.body.userId).toBe(contextId) + expect(posted[0]?.body.userId).not.toBe(attackerId) + }) +})