[object Object]

← 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

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 →