Skip to content

Commit 9c5bd0b

Browse files
committed
security: Phase 2 re-audit fixes — auth, input, deps, infra, polish
Auth & Input Security: - C2: verified_email check now uses !profile.verified_email (catches undefined, not just false — blocks unverified Google emails) - C3: Chat messages sanitized per-message (500 char limit, control chars stripped) — prevents prompt injection via conversation history - H1: IP-based rate limiting (60 req/min) on unauthenticated endpoints /api/feed GET and /api/nearby POST (prevents scraping + Overpass abuse) Infrastructure: - H4: Node.js 20→22 (20 EOL April 30 2026) in amplify.yml, ci.yml, .nvmrc - H5: Fix GitHub Actions SHA version comment (v4.1.7→v4.1.1 for checkout) Dependencies: - H7: Remove 3 unused deps — @aws-sdk/client-s3, @aws-sdk/s3-request-presigner, zustand (zero imports found). Saves 22 packages. - H8: Add brace-expansion@^2.0.1 npm override (moderate DoS fix) Production Polish: - Create app/not-found.tsx — branded 404 page with Go Home + Dashboard links (replaces default Next.js 404)
1 parent e303870 commit 9c5bd0b

10 files changed

Lines changed: 131 additions & 452 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ jobs:
2727
github.event.pull_request.head.repo.full_name == github.repository
2828
2929
steps:
30-
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7
30+
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
3131

3232
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.3
3333
with:
34-
node-version: 20
34+
node-version: 22
3535
cache: npm
3636

3737
- name: Install dependencies

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20
1+
22

amplify.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ frontend:
33
phases:
44
preBuild:
55
commands:
6-
- nvm use 20
6+
- nvm use 22
77
- npm ci
88
build:
99
commands:

app/api/auth/callback/google/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ export async function GET(request: NextRequest) {
8787
};
8888

8989
// ─── Verify email is confirmed by Google ─────────────────────
90-
if (profile.verified_email === false) {
90+
// Use !profile.verified_email (not === false) to also reject
91+
// undefined — handles edge case where Google omits the field.
92+
if (!profile.verified_email) {
9193
return NextResponse.redirect(`${appUrl}/auth/login?error=email_not_verified`);
9294
}
9395

app/api/chat/conversation/route.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,25 @@ export async function POST(req: NextRequest) {
283283
});
284284

285285
for (const msg of messages) {
286+
// Sanitize each message: limit length, strip control chars
287+
const safeContent = String(msg.content || "")
288+
.slice(0, 500)
289+
.replace(/[\x00-\x1f\x7f]/g, "");
290+
286291
if (msg.role === "assistant") {
287292
// Wrap plain-text assistant messages in JSON so Nova stays in JSON-output mode
288-
const isJson = msg.content.trimStart().startsWith("{");
293+
const isJson = safeContent.trimStart().startsWith("{");
289294
const wrapped = isJson
290-
? msg.content
291-
: JSON.stringify({ text: msg.content, turnType: "question", expectsResponse: true, responseRelevance: 0.5, shouldEnd: false, domain: "general", action: null });
295+
? safeContent
296+
: JSON.stringify({ text: safeContent, turnType: "question", expectsResponse: true, responseRelevance: 0.5, shouldEnd: false, domain: "general", action: null });
292297
novaMessages.push({
293298
role: "assistant",
294299
content: [{ text: wrapped }],
295300
});
296301
} else {
297302
novaMessages.push({
298303
role: "user",
299-
content: [{ text: `The child said: "${msg.content}"` }],
304+
content: [{ text: `The child said: "${safeContent}"` }],
300305
});
301306
}
302307
}

app/api/feed/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,16 @@ async function getUser(request: NextRequest) {
6464
}
6565
}
6666

67-
// ─── GET /api/feed — List posts ──────────────────────────────────────
67+
// ─── GET /api/feed — List posts (public, IP-rate-limited) ───────────
6868
export async function GET(request: NextRequest) {
69+
// IP-based rate limiting for unauthenticated endpoint
70+
const { apiRateLimiter } = await import("@/app/lib/rateLimit");
71+
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
72+
const rl = apiRateLimiter.check(`feed-get:${ip}`);
73+
if (!rl.allowed) {
74+
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
75+
}
76+
6977
const { searchParams } = new URL(request.url);
7078
const limit = Math.min(parseInt(searchParams.get("limit") || "50", 10), 100);
7179
const category = searchParams.get("category") || undefined;

app/api/nearby/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ interface NearbyResult {
2323
const OVERPASS_URL = "https://overpass-api.de/api/interpreter";
2424

2525
export async function POST(req: NextRequest) {
26+
// IP-based rate limiting (unauthenticated endpoint, proxies to Overpass)
27+
const { apiRateLimiter } = await import("@/app/lib/rateLimit");
28+
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
29+
const rl = apiRateLimiter.check(`nearby:${ip}`);
30+
if (!rl.allowed) {
31+
return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
32+
}
33+
2634
let body: { lat: number; lng: number; radius?: number };
2735

2836
try {

app/not-found.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import Link from "next/link";
2+
3+
export default function NotFound() {
4+
return (
5+
<div
6+
style={{
7+
display: "flex",
8+
flexDirection: "column",
9+
alignItems: "center",
10+
justifyContent: "center",
11+
minHeight: "100vh",
12+
fontFamily: "'Nunito', system-ui, sans-serif",
13+
background: "var(--bg, #fdf9f3)",
14+
color: "var(--text-primary, #2d3a30)",
15+
padding: "2rem",
16+
textAlign: "center",
17+
}}
18+
>
19+
<div style={{ fontSize: "4rem", marginBottom: "1rem" }}>🧩</div>
20+
<h1
21+
style={{
22+
fontFamily: "'Fredoka', sans-serif",
23+
fontSize: "1.8rem",
24+
fontWeight: 600,
25+
marginBottom: "0.5rem",
26+
}}
27+
>
28+
Page not found
29+
</h1>
30+
<p
31+
style={{
32+
color: "var(--text-secondary, #5a7060)",
33+
marginBottom: "2rem",
34+
maxWidth: "400px",
35+
lineHeight: 1.6,
36+
}}
37+
>
38+
The page you're looking for doesn't exist or has been moved.
39+
</p>
40+
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap", justifyContent: "center" }}>
41+
<Link
42+
href="/"
43+
style={{
44+
padding: "12px 28px",
45+
background: "var(--sage-500, #4d8058)",
46+
color: "white",
47+
borderRadius: "12px",
48+
fontSize: "1rem",
49+
fontWeight: 600,
50+
textDecoration: "none",
51+
}}
52+
>
53+
Go Home
54+
</Link>
55+
<Link
56+
href="/kid-dashboard"
57+
style={{
58+
padding: "12px 28px",
59+
background: "var(--card, #ffffff)",
60+
color: "var(--text-primary, #2d3a30)",
61+
border: "2px solid var(--border, #e3ede6)",
62+
borderRadius: "12px",
63+
fontSize: "1rem",
64+
fontWeight: 600,
65+
textDecoration: "none",
66+
}}
67+
>
68+
Dashboard
69+
</Link>
70+
</div>
71+
</div>
72+
);
73+
}

0 commit comments

Comments
 (0)