Skip to content

Commit 5aaca5f

Browse files
committed
security: Phase 2 fixes — biomarker validation, input hardening, CSP, loading states
- Add biomarker sanitizer (prompt injection prevention for Bedrock LLM) - Apply sanitizer to /api/report/clinical and /api/report/summary - Strip HTML tags from feed post content before storage - Validate feed action field against allowlist - Validate animalPersonality against allowlist in chat route - Clamp ageMonths to 0-240 across chat and report routes - Add loading.tsx at root, intake, kid-dashboard, games - Add error.tsx boundaries at intake, kid-dashboard, games - Add robots.txt (disallow /api/, /intake/, /kid-dashboard/) - Add OpenGraph + Twitter Card metadata to layout.tsx - Harden CSP: add object-src none, base-uri self, form-action self - Add lint + type-check to amplify.yml preBuild - Fix DOCS.md version (16.1.6 -> 16.2.2) - Remove unused S3_MODELS_BUCKET from .env.local.example - Document 13 deferred security items in DOCS.md
1 parent 8118041 commit 5aaca5f

19 files changed

Lines changed: 381 additions & 9 deletions

File tree

.env.local.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ DYNAMODB_CHILD_PROFILES_TABLE=autisense-child-profiles
2525
DYNAMODB_SESSION_SUMMARIES_TABLE=autisense-session-summaries
2626
DYNAMODB_FEED_POSTS_TABLE=autisense-feed-posts
2727

28-
# ── S3 ────────────────────────────────────────────
29-
S3_MODELS_BUCKET=autisense-models
28+
# ── S3 (removed — models served from public/ via CDN) ──
29+
# S3_MODELS_BUCKET is no longer used at runtime
3030

3131
# ── Amazon Bedrock ────────────────────────────────
3232
BEDROCK_REGION=us-east-1

amplify.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ frontend:
55
commands:
66
- nvm use 22
77
- npm ci
8+
- npm run lint
9+
- npm run type-check
810
- npm run test:unit
911
build:
1012
commands:

app/api/chat/conversation/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,12 @@ export async function POST(req: NextRequest) {
258258

259259
// Input length limits — prevent prompt injection + cost inflation
260260
const childName = String(body.childName).slice(0, 50).replace(/[^\p{L}\p{N}\s'-]/gu, "");
261-
const { ageMonths, turnNumber, animalPersonality } = body;
261+
const ageMonths = Math.max(0, Math.min(240, Number(body.ageMonths) || 36));
262+
const { turnNumber } = body;
263+
const allowedPersonalities = ["dog", "cat", "rabbit", "parrot"];
264+
const animalPersonality = allowedPersonalities.includes(body.animalPersonality as string)
265+
? (body.animalPersonality as string)
266+
: "dog";
262267
const messages = Array.isArray(body.messages) ? body.messages.slice(0, 20) : [];
263268

264269
// Hard cap — force farewell after 15 turns

app/api/chat/generate-words/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ export async function POST(request: NextRequest) {
239239

240240
try {
241241
const body: GenerateRequest = await request.json();
242-
const { ageMonths = 36, count = 6, mode = "words" } = body;
242+
const { count = 6, mode = "words" } = body;
243+
const ageMonths = Math.max(0, Math.min(240, Number(body.ageMonths) || 36));
243244

244245
if (!["words", "sentences", "instructions"].includes(mode)) {
245246
return NextResponse.json({ error: "Invalid mode" }, { status: 400 });

app/api/feed/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,10 @@ export async function POST(request: NextRequest) {
125125
try {
126126
const body = await request.json();
127127
const { action } = body;
128+
const validActions = ["create", "react", "delete"];
129+
if (action && !validActions.includes(action as string)) {
130+
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
131+
}
128132

129133
if (action === "react") return handleReaction(body, user.id);
130134
if (action === "delete") return handleDelete(body, user.id);
@@ -152,7 +156,7 @@ async function handleCreate(body: Record<string, unknown>, userId: string) {
152156
const post: FeedPostItem = {
153157
postId: crypto.randomUUID(),
154158
userId,
155-
content: (content as string).trim(),
159+
content: (content as string).trim().replace(/<[^>]*>/g, ""),
156160
category: category as string,
157161
reactions: { heart: 0, helpful: 0, relate: 0 },
158162
reactedBy: { heart: [], helpful: [], relate: [] },

app/api/report/clinical/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ export async function POST(req: NextRequest) {
241241
);
242242
}
243243

244-
const { biomarkers, childAge } = body;
244+
const { sanitizeBiomarkers } = await import("../../../lib/validation/biomarker");
245+
const biomarkers = sanitizeBiomarkers(body.biomarkers) as unknown as BiomarkerAggregate;
246+
const childAge = body.childAge !== undefined ? Math.max(0, Math.min(240, Number(body.childAge) || 0)) : undefined;
245247

246248
// Always generate the deterministic template first
247249
const baseReport = buildTemplateReport(biomarkers, childAge);

app/api/report/summary/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ export async function POST(req: NextRequest) {
7676
);
7777
}
7878

79-
const { biomarkers } = body;
79+
const { sanitizeBiomarkers } = await import("../../../lib/validation/biomarker");
80+
const biomarkers = sanitizeBiomarkers(body.biomarkers) as unknown as BiomarkerAggregate;
8081

8182
const prompt = `Generate a parent-friendly screening summary for a child based on the following biomarker data. Map to DSM-5 criteria. Keep it to 3-4 paragraphs.
8283

app/games/error.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4+
export default function Error({
5+
error,
6+
reset,
7+
}: {
8+
error: Error & { digest?: string };
9+
reset: () => void;
10+
}) {
11+
return (
12+
<div style={{
13+
display: "flex",
14+
flexDirection: "column",
15+
alignItems: "center",
16+
justifyContent: "center",
17+
minHeight: "60vh",
18+
gap: 16,
19+
padding: 24,
20+
textAlign: "center",
21+
}}>
22+
<h2 style={{ color: "var(--text-primary)", fontSize: "1.2rem", fontWeight: 600 }}>
23+
Something went wrong
24+
</h2>
25+
<p style={{ color: "var(--text-muted)", fontSize: "0.9rem", maxWidth: 400 }}>
26+
Don&apos;t worry — your progress is saved. Try refreshing or click below.
27+
</p>
28+
<button
29+
onClick={reset}
30+
style={{
31+
padding: "10px 24px",
32+
borderRadius: 12,
33+
border: "2px solid var(--sage-300)",
34+
background: "var(--sage-50)",
35+
color: "var(--sage-600)",
36+
fontWeight: 600,
37+
cursor: "pointer",
38+
fontSize: "0.9rem",
39+
}}
40+
>
41+
Try again
42+
</button>
43+
</div>
44+
);
45+
}

app/games/loading.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default function Loading() {
2+
return (
3+
<div style={{
4+
display: "flex",
5+
flexDirection: "column",
6+
alignItems: "center",
7+
justifyContent: "center",
8+
minHeight: "60vh",
9+
gap: 16,
10+
}}>
11+
<div style={{
12+
width: 40,
13+
height: 40,
14+
border: "4px solid var(--sage-200)",
15+
borderTopColor: "var(--sage-500)",
16+
borderRadius: "50%",
17+
animation: "spin 0.8s linear infinite",
18+
}} />
19+
<p style={{ color: "var(--text-muted)", fontSize: "0.9rem" }}>Loading...</p>
20+
</div>
21+
);
22+
}

app/intake/error.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
4+
export default function Error({
5+
error,
6+
reset,
7+
}: {
8+
error: Error & { digest?: string };
9+
reset: () => void;
10+
}) {
11+
return (
12+
<div style={{
13+
display: "flex",
14+
flexDirection: "column",
15+
alignItems: "center",
16+
justifyContent: "center",
17+
minHeight: "60vh",
18+
gap: 16,
19+
padding: 24,
20+
textAlign: "center",
21+
}}>
22+
<h2 style={{ color: "var(--text-primary)", fontSize: "1.2rem", fontWeight: 600 }}>
23+
Something went wrong
24+
</h2>
25+
<p style={{ color: "var(--text-muted)", fontSize: "0.9rem", maxWidth: 400 }}>
26+
Don&apos;t worry — your progress is saved. Try refreshing or click below.
27+
</p>
28+
<button
29+
onClick={reset}
30+
style={{
31+
padding: "10px 24px",
32+
borderRadius: 12,
33+
border: "2px solid var(--sage-300)",
34+
background: "var(--sage-50)",
35+
color: "var(--sage-600)",
36+
fontWeight: 600,
37+
cursor: "pointer",
38+
fontSize: "0.9rem",
39+
}}
40+
>
41+
Try again
42+
</button>
43+
</div>
44+
);
45+
}

0 commit comments

Comments
 (0)