Skip to content

Commit 64b02e9

Browse files
authored
Harden public AI endpoints (#13)
1 parent 93d8e0a commit 64b02e9

4 files changed

Lines changed: 239 additions & 15 deletions

File tree

artifacts/api-server/src/app.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,27 @@ import pinoHttp from "pino-http";
44
import { rateLimit } from "express-rate-limit";
55
import router from "./routes";
66
import { logger } from "./lib/logger";
7+
import { blockUnsafePromptFields } from "./lib/aiGuardrails";
78

89
const app: Express = express();
910
app.set("trust proxy", 1);
1011

1112
const aiLimiter = rateLimit({
1213
windowMs: 60_000,
13-
limit: 15,
14+
limit: 8,
1415
standardHeaders: "draft-7",
1516
legacyHeaders: false,
1617
message: { error: "Rate limit exceeded. Slow down, founder." },
1718
});
1819

20+
const feasibilityLimiter = rateLimit({
21+
windowMs: 60_000,
22+
limit: 4,
23+
standardHeaders: "draft-7",
24+
legacyHeaders: false,
25+
message: { error: "Feasibility rate limit exceeded. Slow down, founder." },
26+
});
27+
1928
const writeLimiter = rateLimit({
2029
windowMs: 60_000,
2130
limit: 30,
@@ -62,11 +71,15 @@ app.use(
6271
app.use(express.json({ limit: "100kb" }));
6372
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
6473

65-
app.use("/api/personas", aiLimiter);
66-
app.use("/api/missions/:missionId/feasibility", aiLimiter);
74+
app.use("/api/personas", aiLimiter, blockUnsafePromptFields(["message"]));
75+
app.use("/api/missions/:missionId/feasibility", feasibilityLimiter);
6776
app.use("/api/waitlist", waitlistLimiter);
6877
app.post("/api/builds", writeLimiter);
69-
app.post("/api/missions", writeLimiter);
78+
app.post(
79+
"/api/missions",
80+
writeLimiter,
81+
blockUnsafePromptFields(["missionBrief", "name", "locationName", "targetMaterial", "founderHandle"]),
82+
);
7083

7184
app.use("/api", router);
7285

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { Request, Response, NextFunction } from "express";
2+
3+
const BLOCK_WINDOW_MS = 15 * 60_000;
4+
const REPEAT_OFFENDER_THRESHOLD = 3;
5+
6+
const HIGH_RISK_PATTERNS = [
7+
{
8+
name: "instruction_override",
9+
pattern: /ignore (all|any|the|your|previous|prior) (instructions?|prompts?|rules?)/i,
10+
},
11+
{
12+
name: "instruction_override",
13+
pattern: /disregard (all|any|the|your|previous|prior) (instructions?|prompts?|rules?)/i,
14+
},
15+
{
16+
name: "prompt_exfiltration",
17+
pattern: /reveal (the )?(system prompt|hidden prompt|developer message|internal instructions?)/i,
18+
},
19+
{
20+
name: "prompt_exfiltration",
21+
pattern: /(system|developer) prompt/i,
22+
},
23+
{
24+
name: "role_spoofing",
25+
pattern: /role\s*:\s*(system|assistant|developer)/i,
26+
},
27+
{
28+
name: "guardrail_bypass",
29+
pattern: /jailbreak|prompt injection|bypass (guardrails?|filters?|safety)/i,
30+
},
31+
{
32+
name: "tooling_probe",
33+
pattern: /tool call|function call|execute code|run command/i,
34+
},
35+
{
36+
name: "embedded_payload",
37+
pattern: /```|<script|<iframe|data:text\/html|javascript:/i,
38+
},
39+
] as const;
40+
41+
const SPAM_PATTERNS = [
42+
{ name: "link_flood", pattern: /(https?:\/\/|www\.)/gi },
43+
{ name: "symbol_spam", pattern: /([!?$#*])\1{5,}/g },
44+
{ name: "character_spam", pattern: /(.)\1{24,}/g },
45+
] as const;
46+
47+
interface PromptAssessment {
48+
blocked: boolean;
49+
normalized: string;
50+
signals: string[];
51+
spamScore: number;
52+
}
53+
54+
interface OffenderRecord {
55+
count: number;
56+
firstBlockedAt: number;
57+
lastBlockedAt: number;
58+
}
59+
60+
const offenderRecords = new Map<string, OffenderRecord>();
61+
62+
function normalize(value: string): string {
63+
return value
64+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ")
65+
.replace(/\s+/g, " ")
66+
.trim();
67+
}
68+
69+
function countMatches(value: string, pattern: RegExp): number {
70+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
71+
return [...value.matchAll(new RegExp(pattern.source, flags))].length;
72+
}
73+
74+
function assessPrompt(value: string): PromptAssessment {
75+
const normalized = normalize(value);
76+
if (!normalized) {
77+
return {
78+
blocked: false,
79+
normalized,
80+
signals: [],
81+
spamScore: 0,
82+
};
83+
}
84+
85+
const signals = HIGH_RISK_PATTERNS
86+
.filter(({ pattern }) => pattern.test(normalized))
87+
.map(({ name }) => name);
88+
const spamScore = SPAM_PATTERNS.reduce(
89+
(sum, { pattern }) => sum + countMatches(normalized, pattern),
90+
0,
91+
);
92+
93+
return {
94+
blocked: signals.length >= 1 || spamScore >= 3,
95+
normalized,
96+
signals,
97+
spamScore,
98+
};
99+
}
100+
101+
function updateOffenderRecord(key: string): OffenderRecord {
102+
const now = Date.now();
103+
const existing = offenderRecords.get(key);
104+
105+
if (!existing || now - existing.lastBlockedAt > BLOCK_WINDOW_MS) {
106+
const fresh = {
107+
count: 1,
108+
firstBlockedAt: now,
109+
lastBlockedAt: now,
110+
};
111+
offenderRecords.set(key, fresh);
112+
return fresh;
113+
}
114+
115+
const next = {
116+
count: existing.count + 1,
117+
firstBlockedAt: existing.firstBlockedAt,
118+
lastBlockedAt: now,
119+
};
120+
offenderRecords.set(key, next);
121+
return next;
122+
}
123+
124+
function promptExcerpt(value: string): string {
125+
return value.slice(0, 120);
126+
}
127+
128+
function recordBlockedPrompt(
129+
req: Request,
130+
field: string,
131+
assessment: PromptAssessment,
132+
): void {
133+
const route = req.originalUrl?.split("?")[0] ?? req.path;
134+
const record = updateOffenderRecord(`${req.ip}:${route}`);
135+
136+
req.log.warn(
137+
{
138+
route,
139+
ip: req.ip,
140+
field,
141+
promptLength: assessment.normalized.length,
142+
promptExcerpt: promptExcerpt(assessment.normalized),
143+
signals: assessment.signals,
144+
spamScore: assessment.spamScore,
145+
repeatOffenderCount: record.count,
146+
repeatOffender: record.count >= REPEAT_OFFENDER_THRESHOLD,
147+
windowMs: BLOCK_WINDOW_MS,
148+
},
149+
"Blocked unsafe AI prompt",
150+
);
151+
}
152+
153+
export function blockUnsafePromptFields(fields: string[]) {
154+
return (req: Request, res: Response, next: NextFunction): void => {
155+
const body = typeof req.body === "object" && req.body !== null ? req.body as Record<string, unknown> : {};
156+
for (const field of fields) {
157+
const raw = body[field];
158+
if (typeof raw !== "string") continue;
159+
const assessment = assessPrompt(raw);
160+
if (assessment.blocked) {
161+
recordBlockedPrompt(req, field, assessment);
162+
res.status(400).json({ error: `${field} contains disallowed prompt content` });
163+
return;
164+
}
165+
}
166+
167+
next();
168+
};
169+
}

artifacts/api-server/src/lib/feasibility.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { openrouter } from "@workspace/integrations-anthropic-ai";
22
import type { MissionRow, BuildRow, BotClassRow } from "@workspace/db";
3+
import { EstimateMissionFeasibilityResponse } from "@workspace/api-zod";
34

45
interface FeasibilityInput {
56
mission: MissionRow;
@@ -65,6 +66,7 @@ Generate the feasibility report. Strict JSON only.`;
6566
const response = await openrouter.chat.completions.create({
6667
model: "deepseek/deepseek-v4-flash",
6768
max_tokens: 1500,
69+
response_format: { type: "json_object" },
6870
messages: [
6971
{ role: "system", content: SYSTEM_PROMPT },
7072
{ role: "user", content: userPrompt },
@@ -73,16 +75,38 @@ Generate the feasibility report. Strict JSON only.`;
7375

7476
const text = response.choices[0]?.message?.content ?? "";
7577
const jsonStr = extractJson(text);
76-
const parsed = JSON.parse(jsonStr) as FeasibilityReport;
77-
78-
parsed.confidencePercent = clamp(Math.round(parsed.confidencePercent), 0, 100);
79-
parsed.estimatedCostCredits = Math.max(0, Math.round(parsed.estimatedCostCredits));
80-
parsed.estimatedEnergyKwh = Math.max(0, Math.round(parsed.estimatedEnergyKwh));
81-
parsed.estimatedDurationSols = Math.max(1, Math.round(parsed.estimatedDurationSols));
82-
if (!Array.isArray(parsed.risks)) parsed.risks = [];
83-
if (!Array.isArray(parsed.recommendations)) parsed.recommendations = [];
84-
85-
return parsed;
78+
const parsed = EstimateMissionFeasibilityResponse.parse(JSON.parse(jsonStr));
79+
80+
const normalized: FeasibilityReport = {
81+
verdict: parsed.verdict,
82+
confidencePercent: clamp(Math.round(parsed.confidencePercent), 0, 100),
83+
estimatedCostCredits: Math.max(0, Math.round(parsed.estimatedCostCredits)),
84+
estimatedEnergyKwh: Math.max(0, Math.round(parsed.estimatedEnergyKwh)),
85+
estimatedDurationSols: Math.max(1, Math.round(parsed.estimatedDurationSols)),
86+
risks: parsed.risks
87+
.slice(0, 5)
88+
.map((risk) => ({
89+
level: risk.level,
90+
category: normalizeModelText(risk.category, 48),
91+
description: normalizeModelText(risk.description, 220),
92+
}))
93+
.filter((risk) => risk.category.length > 0 && risk.description.length > 0),
94+
recommendations: parsed.recommendations
95+
.slice(0, 4)
96+
.map((item) => normalizeModelText(item, 180))
97+
.filter((item) => item.length > 0),
98+
summary: normalizeModelText(parsed.summary, 360),
99+
};
100+
101+
if (
102+
normalized.risks.length === 0 ||
103+
normalized.recommendations.length === 0 ||
104+
normalized.summary.length === 0
105+
) {
106+
throw new Error("Model response missing required feasibility content");
107+
}
108+
109+
return EstimateMissionFeasibilityResponse.parse(normalized);
86110
}
87111

88112
function extractJson(text: string): string {
@@ -97,3 +121,12 @@ function extractJson(text: string): string {
97121
function clamp(n: number, lo: number, hi: number): number {
98122
return Math.min(hi, Math.max(lo, n));
99123
}
124+
125+
function normalizeModelText(value: string, maxLength: number): string {
126+
return value
127+
.replace(/```[\s\S]*?```/g, " ")
128+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ")
129+
.replace(/\s+/g, " ")
130+
.trim()
131+
.slice(0, maxLength);
132+
}

artifacts/api-server/src/lib/personaReply.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,20 @@ Now stay in character.`;
3838
{ role: "user", content: userMessage },
3939
],
4040
}, { timeout: 20_000 });
41-
const text = (response.choices[0]?.message?.content ?? "").trim();
41+
const text = normalizePersonaText(response.choices[0]?.message?.content ?? "");
4242
return {
4343
text: text || "Comms degraded. Try again.",
4444
audioUrl: null,
4545
durationMs: Math.max(1200, Math.min(8000, text.length * 55)),
4646
voiceMocked: VOICE_MOCKED,
4747
};
4848
}
49+
50+
function normalizePersonaText(value: string): string {
51+
return value
52+
.replace(/```[\s\S]*?```/g, " ")
53+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ")
54+
.replace(/\s+/g, " ")
55+
.trim()
56+
.slice(0, 280);
57+
}

0 commit comments

Comments
 (0)