security: cap assistant chat payload + injection-guard project cover prompt (#38)
`messages[].content` and `pageContext` had no `.max()` — a single chat turn could ship 50 MB / 200 messages and OOM JSON.parse, balloon prompt assembly, and burn arbitrary AI-provider cost. Separately, the project-cover image-generation path concatenated user free-text into the DALL-E / Gemini prompt without any injection check, so a manager could pivot the image model into "ignore previous instructions" / role-override style attacks against downstream prompt-aware infra. - assistant-procedure-support: add `.max(10_000)` per message, `.max(2_000)` on pageContext, and a `.superRefine` aggregate cap (200 KB total bytes across all messages + page context). Constants exported so call sites and tests share one source of truth. - project-cover.generateCover: run `checkPromptInjection` over the user-supplied `prompt` field; reject with BAD_REQUEST on match. - 7 schema-bound tests covering per-message, page-context, aggregate, message-count, and happy-path cases. Covers EAPPS 3.2.7 (input bounds) / EGAI 4.6.3.2 (prompt-injection detection on user inputs). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ASSISTANT_MAX_AGGREGATE_BYTES,
|
||||
ASSISTANT_MAX_CONTENT_LENGTH,
|
||||
ASSISTANT_MAX_PAGE_CONTEXT,
|
||||
assistantChatInputSchema,
|
||||
} from "../router/assistant-procedure-support.js";
|
||||
|
||||
describe("assistantChatInputSchema bounds", () => {
|
||||
it("accepts a normal-sized message", () => {
|
||||
const result = assistantChatInputSchema.safeParse({
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a single message above the per-message length cap", () => {
|
||||
const huge = "x".repeat(ASSISTANT_MAX_CONTENT_LENGTH + 1);
|
||||
const result = assistantChatInputSchema.safeParse({
|
||||
messages: [{ role: "user", content: huge }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a pageContext above the page-context cap", () => {
|
||||
const huge = "x".repeat(ASSISTANT_MAX_PAGE_CONTEXT + 1);
|
||||
const result = assistantChatInputSchema.safeParse({
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
pageContext: huge,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an aggregate payload above the total-bytes cap", () => {
|
||||
// Each message is below the per-message cap, but together they exceed
|
||||
// the aggregate cap.
|
||||
const oneMessageBytes = 5_000;
|
||||
const each = "x".repeat(oneMessageBytes);
|
||||
const count = Math.ceil(ASSISTANT_MAX_AGGREGATE_BYTES / oneMessageBytes) + 2;
|
||||
const messages = Array.from({ length: count }, () => ({
|
||||
role: "user" as const,
|
||||
content: each,
|
||||
}));
|
||||
const result = assistantChatInputSchema.safeParse({ messages });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an aggregate payload right under the cap", () => {
|
||||
const count = Math.floor(ASSISTANT_MAX_AGGREGATE_BYTES / 1_000) - 1;
|
||||
const messages = Array.from({ length: count }, () => ({
|
||||
role: "user" as const,
|
||||
content: "x".repeat(1_000),
|
||||
}));
|
||||
const result = assistantChatInputSchema.safeParse({ messages });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty messages array", () => {
|
||||
const result = assistantChatInputSchema.safeParse({ messages: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 200 messages", () => {
|
||||
const messages = Array.from({ length: 201 }, () => ({
|
||||
role: "user" as const,
|
||||
content: "x",
|
||||
}));
|
||||
const result = assistantChatInputSchema.safeParse({ messages });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user