diff --git a/packages/core/src/routes/public-forms.test.ts b/packages/core/src/routes/public-forms.test.ts
new file mode 100644
index 000000000..a1e9587db
--- /dev/null
+++ b/packages/core/src/routes/public-forms.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { Hono } from 'hono'
+
+// Track what gets stored
+let capturedSubmissionData: string | null = null
+
+// Mock TurnstileService — disabled by default so submissions go through
+vi.mock('../plugins/core-plugins/turnstile-plugin/services/turnstile', () => ({
+ TurnstileService: class MockTurnstileService {
+ getSettings = vi.fn().mockResolvedValue(null)
+ isEnabled = vi.fn().mockResolvedValue(false)
+ verifyToken = vi.fn().mockResolvedValue({ success: true })
+ }
+}))
+
+import { publicFormsRoutes } from './public-forms'
+
+function createMockDb(formExists = true) {
+ return {
+ prepare: vi.fn().mockImplementation((sql: string) => {
+ // Form lookup
+ if (sql.includes('FROM forms WHERE')) {
+ return {
+ bind: vi.fn().mockReturnThis(),
+ first: vi.fn().mockResolvedValue(
+ formExists
+ ? {
+ id: 'form-1',
+ name: 'test_form',
+ display_name: 'Test Form',
+ is_active: 1,
+ turnstile_enabled: 0,
+ turnstile_settings: null
+ }
+ : null
+ ),
+ all: vi.fn().mockResolvedValue({ results: [] }),
+ run: vi.fn().mockResolvedValue({ success: true })
+ }
+ }
+ // INSERT into form_submissions — capture the data
+ if (sql.includes('INSERT INTO form_submissions')) {
+ return {
+ bind: vi.fn().mockImplementation((...args: any[]) => {
+ // submission_data is the 3rd bound param (index 2)
+ capturedSubmissionData = args[2]
+ return {
+ run: vi.fn().mockResolvedValue({ success: true }),
+ first: vi.fn().mockResolvedValue(null),
+ all: vi.fn().mockResolvedValue({ results: [] })
+ }
+ }),
+ run: vi.fn().mockResolvedValue({ success: true }),
+ first: vi.fn().mockResolvedValue(null),
+ all: vi.fn().mockResolvedValue({ results: [] })
+ }
+ }
+ // UPDATE forms (submission count)
+ if (sql.includes('UPDATE forms')) {
+ return {
+ bind: vi.fn().mockReturnThis(),
+ run: vi.fn().mockResolvedValue({ success: true }),
+ first: vi.fn().mockResolvedValue(null),
+ all: vi.fn().mockResolvedValue({ results: [] })
+ }
+ }
+ // Default
+ return {
+ bind: vi.fn().mockReturnThis(),
+ first: vi.fn().mockResolvedValue(null),
+ all: vi.fn().mockResolvedValue({ results: [] }),
+ run: vi.fn().mockResolvedValue({ success: true })
+ }
+ })
+ }
+}
+
+function createTestApp(db: any) {
+ const app = new Hono()
+
+ app.use('/api/forms/*', async (c, next) => {
+ c.env = { DB: db } as any
+ await next()
+ })
+
+ app.route('/api/forms', publicFormsRoutes)
+ return app
+}
+
+describe('POST /api/forms/:identifier/submit — XSS sanitization', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ capturedSubmissionData = null
+ })
+
+ it('should HTML-encode script tags in string fields', async () => {
+ const db = createMockDb()
+ const app = createTestApp(db)
+
+ const res = await app.request('/api/forms/form-1/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ data: {
+ name: 'John',
+ comment: ''
+ }
+ })
+ })
+
+ const json = await res.json() as any
+ expect(res.status).toBe(200)
+ expect(json.success).toBe(true)
+
+ // Verify stored data has encoded HTML
+ const stored = JSON.parse(capturedSubmissionData!)
+ expect(stored.name).toBe('John')
+ expect(stored.comment).toBe('<script>alert("xss")</script>')
+ })
+
+ it('should sanitize nested objects recursively', async () => {
+ const db = createMockDb()
+ const app = createTestApp(db)
+
+ const res = await app.request('/api/forms/form-1/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ data: {
+ user: {
+ name: '
',
+ address: {
+ street: '123 Main St',
+ city: 'Baltimore'
+ }
+ }
+ }
+ })
+ })
+
+ expect(res.status).toBe(200)
+ const stored = JSON.parse(capturedSubmissionData!)
+ expect(stored.user.name).toBe('<img src=x onerror=alert(1)>')
+ expect(stored.user.address.street).toBe('123 Main St')
+ expect(stored.user.address.city).toBe('<b>Baltimore</b>')
+ })
+
+ it('should sanitize arrays of strings', async () => {
+ const db = createMockDb()
+ const app = createTestApp(db)
+
+ const res = await app.request('/api/forms/form-1/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ data: {
+ tags: ['safe', '', 'also safe']
+ }
+ })
+ })
+
+ expect(res.status).toBe(200)
+ const stored = JSON.parse(capturedSubmissionData!)
+ expect(stored.tags).toEqual([
+ 'safe',
+ '<script>bad</script>',
+ 'also safe'
+ ])
+ })
+
+ it('should pass through numbers, booleans, and null unchanged', async () => {
+ const db = createMockDb()
+ const app = createTestApp(db)
+
+ const res = await app.request('/api/forms/form-1/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ data: {
+ age: 25,
+ active: true,
+ optional: null,
+ name: 'Test'
+ }
+ })
+ })
+
+ expect(res.status).toBe(200)
+ const stored = JSON.parse(capturedSubmissionData!)
+ expect(stored.age).toBe(25)
+ expect(stored.active).toBe(true)
+ expect(stored.optional).toBeNull()
+ expect(stored.name).toBe('Test')
+ })
+
+ it('should handle event handler injection attempts', async () => {
+ const db = createMockDb()
+ const app = createTestApp(db)
+
+ const res = await app.request('/api/forms/form-1/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ data: {
+ field1: '" onmouseover="alert(1)',
+ field2: "' onclick='alert(1)'",
+ field3: '
'
+ }
+ })
+ })
+
+ expect(res.status).toBe(200)
+ const stored = JSON.parse(capturedSubmissionData!)
+ // All angle brackets and quotes should be encoded
+ expect(stored.field1).not.toContain('"')
+ expect(stored.field2).not.toContain("'")
+ expect(stored.field3).not.toContain('<')
+ expect(stored.field3).not.toContain('>')
+ })
+})
diff --git a/packages/core/src/routes/public-forms.ts b/packages/core/src/routes/public-forms.ts
index a525ce883..229889c0d 100644
--- a/packages/core/src/routes/public-forms.ts
+++ b/packages/core/src/routes/public-forms.ts
@@ -1,5 +1,28 @@
import { Hono } from 'hono'
import { TurnstileService } from '../plugins/core-plugins/turnstile-plugin/services/turnstile'
+import { sanitizeInput } from '../utils/sanitize'
+
+/**
+ * Recursively sanitize all string values in arbitrary JSON data.
+ * HTML-encodes entities (e.g., < becomes <) to prevent stored XSS
+ * when form submission data is rendered in admin templates.
+ */
+function sanitizeDeep(value: unknown): unknown {
+ if (typeof value === 'string') {
+ return sanitizeInput(value)
+ }
+ if (Array.isArray(value)) {
+ return value.map(sanitizeDeep)
+ }
+ if (value !== null && typeof value === 'object') {
+ const result: Record = {}
+ for (const [k, v] of Object.entries(value)) {
+ result[k] = sanitizeDeep(v)
+ }
+ return result
+ }
+ return value // numbers, booleans, null pass through
+}
type Bindings = {
DB: D1Database
@@ -538,6 +561,10 @@ publicFormsRoutes.post('/:identifier/submit', async (c) => {
}
}
+ // Sanitize all string values in submission data to prevent stored XSS.
+ // HTML-encodes entities (e.g., < becomes <) before storage.
+ const sanitizedData = sanitizeDeep(body.data) as Record
+
// Create submission
const submissionId = crypto.randomUUID()
const now = Date.now()
@@ -550,7 +577,7 @@ publicFormsRoutes.post('/:identifier/submit', async (c) => {
`).bind(
submissionId,
form.id,
- JSON.stringify(body.data),
+ JSON.stringify(sanitizedData),
null, // user_id (for authenticated users)
c.req.header('cf-connecting-ip') || null,
c.req.header('user-agent') || null,