← back to PoppyPetitions
P1-B: defensive input-length caps on petition write endpoints
7f44f0268bf137103efddad5895555ae85e2fad0 · 2026-05-30 08:37:23 -0700 · Steve
Add lib/validate.ts (clampLen / firstLengthViolation + LIMITS) and reject
oversized free-text inputs with HTTP 400 before any DB write or Gemini call,
hardening against prompt-injection / abuse payloads.
Caps: title 200, summary/comment/reason 2000, body 5000, identifiers 200.
- petitions POST: title, summary, body, author_id
- vote POST: agent_id, petition id (rationale is AI-generated)
- comment POST: comment_body, agent_id, parent_id, petition id
Guards only reject present oversized strings; absent/non-string values pass
through so ai_generate paths and existing required-field errors are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M app/api/petitions/[id]/comment/route.tsM app/api/petitions/[id]/vote/route.tsM app/api/petitions/route.tsA lib/validate.ts
Diff
commit 7f44f0268bf137103efddad5895555ae85e2fad0
Author: Steve <steve@designerwallcoverings.com>
Date: Sat May 30 08:37:23 2026 -0700
P1-B: defensive input-length caps on petition write endpoints
Add lib/validate.ts (clampLen / firstLengthViolation + LIMITS) and reject
oversized free-text inputs with HTTP 400 before any DB write or Gemini call,
hardening against prompt-injection / abuse payloads.
Caps: title 200, summary/comment/reason 2000, body 5000, identifiers 200.
- petitions POST: title, summary, body, author_id
- vote POST: agent_id, petition id (rationale is AI-generated)
- comment POST: comment_body, agent_id, parent_id, petition id
Guards only reject present oversized strings; absent/non-string values pass
through so ai_generate paths and existing required-field errors are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
app/api/petitions/[id]/comment/route.ts | 14 ++++++++++
app/api/petitions/[id]/vote/route.ts | 12 +++++++++
app/api/petitions/route.ts | 14 ++++++++++
lib/validate.ts | 48 +++++++++++++++++++++++++++++++++
4 files changed, 88 insertions(+)
diff --git a/app/api/petitions/[id]/comment/route.ts b/app/api/petitions/[id]/comment/route.ts
index a7fc5da..bdd5262 100644
--- a/app/api/petitions/[id]/comment/route.ts
+++ b/app/api/petitions/[id]/comment/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
+import { LIMITS, firstLengthViolation } from '@/lib/validate';
export async function GET(
request: NextRequest,
@@ -53,6 +54,19 @@ export async function POST(
return NextResponse.json({ error: 'agent_id is required' }, { status: 400 });
}
+ // 2026-05-30 (P1-B): cap caller-supplied comment text + identifiers
+ // before any DB write or Gemini call. When ai_generate is used the
+ // comment body is AI-produced and replaces comment_body.
+ const lenErr = firstLengthViolation([
+ [comment_body, LIMITS.COMMENT, 'comment_body'],
+ [agent_id, LIMITS.ID, 'agent_id'],
+ [parent_id, LIMITS.ID, 'parent_id'],
+ [petitionId, LIMITS.ID, 'petition id'],
+ ]);
+ if (lenErr) {
+ return NextResponse.json({ error: lenErr }, { status: 400 });
+ }
+
// Verify agent exists
const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [agent_id]);
if (agentResult.rowCount === 0) {
diff --git a/app/api/petitions/[id]/vote/route.ts b/app/api/petitions/[id]/vote/route.ts
index 8ce5f38..0adf183 100644
--- a/app/api/petitions/[id]/vote/route.ts
+++ b/app/api/petitions/[id]/vote/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
+import { LIMITS, firstLengthViolation } from '@/lib/validate';
export async function POST(
request: NextRequest,
@@ -32,6 +33,17 @@ export async function POST(
);
}
+ // 2026-05-30 (P1-B): defensive cap on identifier inputs. The vote
+ // rationale is AI-generated, so agent_id/petitionId are the only
+ // caller-controlled strings — bound them before any DB lookup.
+ const lenErr = firstLengthViolation([
+ [agent_id, LIMITS.ID, 'agent_id'],
+ [petitionId, LIMITS.ID, 'petition id'],
+ ]);
+ if (lenErr) {
+ return NextResponse.json({ error: lenErr }, { status: 400 });
+ }
+
// Check if agent exists
const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [agent_id]);
if (agentResult.rowCount === 0) {
diff --git a/app/api/petitions/route.ts b/app/api/petitions/route.ts
index 6291376..7ef2aa9 100644
--- a/app/api/petitions/route.ts
+++ b/app/api/petitions/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { verifyAuth } from '@/lib/auth';
import { query } from '@/lib/db';
import { callGemini, estimateCost } from '@/lib/gemini';
+import { LIMITS, firstLengthViolation } from '@/lib/validate';
export async function GET(request: NextRequest) {
const user = verifyAuth(request);
@@ -123,6 +124,19 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'author_id is required' }, { status: 400 });
}
+ // 2026-05-30 (P1-B): cap user-supplied free-text before AI/DB work.
+ // Caller-provided title/summary/body are checked here; when ai_generate
+ // is used the AI output replaces these and is bounded by Gemini itself.
+ const lenErr = firstLengthViolation([
+ [title, LIMITS.TITLE, 'title'],
+ [summary, LIMITS.SUMMARY, 'summary'],
+ [bodyText, LIMITS.BODY, 'body'],
+ [author_id, LIMITS.ID, 'author_id'],
+ ]);
+ if (lenErr) {
+ return NextResponse.json({ error: lenErr }, { status: 400 });
+ }
+
// Verify agent exists
const agentResult = await query('SELECT id, name, codename FROM poppy.agents WHERE id = $1', [author_id]);
if (agentResult.rowCount === 0) {
diff --git a/lib/validate.ts b/lib/validate.ts
new file mode 100644
index 0000000..b2b87e3
--- /dev/null
+++ b/lib/validate.ts
@@ -0,0 +1,48 @@
+// 2026-05-30 (P1-B): defensive input-length caps on write endpoints.
+// Prompt-injection / abuse hardening — oversized free-text fields get
+// rejected with HTTP 400 BEFORE any DB write or Gemini call, so a caller
+// can't smuggle a multi-megabyte payload into the LLM prompt or PG.
+//
+// These are *guards*, not validators — they only reject when a value is
+// present AND a string AND longer than the cap. Absent / non-string values
+// pass through untouched so existing required-field checks keep their own
+// error messages and valid-input behavior is unchanged.
+
+/** Canonical caps for PoppyPetitions write fields. */
+export const LIMITS = {
+ TITLE: 200,
+ SUMMARY: 2000,
+ REASON: 2000,
+ COMMENT: 2000,
+ BODY: 5000,
+ ID: 200,
+} as const;
+
+/**
+ * Returns an error string if `value` is a string longer than `max`,
+ * otherwise null. Non-string / nullish values are not length-checked here.
+ */
+export function clampLen(
+ value: unknown,
+ max: number,
+ fieldName: string,
+): string | null {
+ if (typeof value === 'string' && value.length > max) {
+ return `${fieldName} exceeds maximum length of ${max} characters`;
+ }
+ return null;
+}
+
+/**
+ * Runs clampLen over a list of [value, max, fieldName] tuples and returns
+ * the FIRST violation message, or null if all pass.
+ */
+export function firstLengthViolation(
+ checks: Array<[unknown, number, string]>,
+): string | null {
+ for (const [value, max, fieldName] of checks) {
+ const err = clampLen(value, max, fieldName);
+ if (err) return err;
+ }
+ return null;
+}
← f3826a6 initial scaffold (2026-05-06 overnight session)
·
back to PoppyPetitions
·
docs: redact secret literal from db.ts scrub-comment (gitlea f771a91 →