Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/forms/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -79,12 +82,12 @@ const getClawHubHeaders = () => {
}
}

const clawHubUnbanRequest = async (
export const clawHubUnbanRequest = async (
action: Extract<FormAction, { type: "clawhub.unbanUser" }>,
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.")
}
Expand Down
27 changes: 27 additions & 0 deletions src/forms/payload.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}
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<string, string>,
context: Record<string, string>
) => ({
...collected,
...context
})
19 changes: 3 additions & 16 deletions src/forms/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, string> = {}
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"
Expand Down Expand Up @@ -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) {
Expand All @@ -389,10 +379,7 @@ const handleFormSubmit = async (request: Request, form: FormConfig, client: Clie
if (error) {
return new Response(renderPage(form.title, <FormRoute form={form} session={collected.session} user={user} values={context} error={error} />), { 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,
Expand Down
149 changes: 149 additions & 0 deletions test/forms/clawhub-unban.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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)
})
})