Skip to content

Commit f39b87c

Browse files
Merge pull request #220 from SaplingLearn/security/199-newsletter-careers-bounds
fix(security): newsletter exception leak + careers upload bounds (#199)
2 parents f4b9174 + 5bd3cbe commit f39b87c

3 files changed

Lines changed: 156 additions & 10 deletions

File tree

backend/routes/careers.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,52 @@
1+
import re
12
import uuid
23

34
import httpx
4-
from fastapi import APIRouter, UploadFile, File, Form
5+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
56
from typing import Optional
67

78
from db.connection import SUPABASE_URL, SUPABASE_KEY, table
89

910
BUCKET = "application_resumes"
1011

12+
# /apply is intentionally public (anyone can apply), so the upload path must be
13+
# bounded — otherwise it's an unauthenticated, unbounded upload + DB-write sink
14+
# (#199). Cap size and restrict to document types, mirroring storage_service's
15+
# avatar validation (415 unsupported / 413 too large).
16+
MAX_RESUME_SIZE = 5 * 1024 * 1024 # 5 MB
17+
ALLOWED_RESUME_TYPES = {
18+
"application/pdf",
19+
"application/msword",
20+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
21+
}
22+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
23+
1124
router = APIRouter()
1225

1326

14-
def _upload_resume(file: UploadFile) -> str:
15-
"""Upload PDF to Supabase Storage and return the storage path."""
16-
ext = file.filename.rsplit(".", 1)[-1] if "." in file.filename else "pdf"
27+
def _validate_resume(file: UploadFile) -> bytes:
28+
"""Enforce content-type + size bounds and return the file bytes.
29+
30+
Reads at most MAX_RESUME_SIZE + 1 bytes so an oversize upload can't be
31+
pulled fully into memory before we reject it.
32+
"""
33+
if (file.content_type or "") not in ALLOWED_RESUME_TYPES:
34+
raise HTTPException(
35+
status_code=415,
36+
detail="Unsupported resume type. Allowed: PDF, DOC, DOCX.",
37+
)
38+
content = file.file.read(MAX_RESUME_SIZE + 1)
39+
if len(content) > MAX_RESUME_SIZE:
40+
raise HTTPException(status_code=413, detail="Resume too large. Maximum size is 5 MB.")
41+
if not content:
42+
raise HTTPException(status_code=400, detail="Resume file is empty.")
43+
return content
44+
45+
46+
def _upload_resume(file: UploadFile, content: bytes) -> str:
47+
"""Upload a validated resume to Supabase Storage and return the path."""
48+
ext = file.filename.rsplit(".", 1)[-1] if file.filename and "." in file.filename else "pdf"
1749
path = f"{uuid.uuid4()}.{ext}"
18-
content = file.file.read()
1950
url = f"{SUPABASE_URL}/storage/v1/object/{BUCKET}/{path}"
2051
r = httpx.put(
2152
url,
@@ -40,14 +71,31 @@ async def apply(
4071
portfolio_link: str = Form(""),
4172
resume: Optional[UploadFile] = File(None),
4273
):
43-
resume_path = _upload_resume(resume) if resume else None
74+
position = position.strip()
75+
full_name = full_name.strip()
76+
email = email.strip()
77+
linkedin_url = linkedin_url.strip()
78+
if not position or len(position) > 200:
79+
raise HTTPException(status_code=422, detail="A valid position is required.")
80+
if not full_name or len(full_name) > 200:
81+
raise HTTPException(status_code=422, detail="Full name is required.")
82+
if not _EMAIL_RE.match(email) or len(email) > 320:
83+
raise HTTPException(status_code=422, detail="A valid email is required.")
84+
if not linkedin_url or len(linkedin_url) > 500:
85+
raise HTTPException(status_code=422, detail="A LinkedIn URL is required.")
86+
87+
resume_path = None
88+
if resume is not None and resume.filename:
89+
content = _validate_resume(resume)
90+
resume_path = _upload_resume(resume, content)
91+
4492
row = table("job_applications").insert({
4593
"position": position,
4694
"full_name": full_name,
4795
"email": email,
48-
"phone": phone or None,
96+
"phone": (phone or "").strip() or None,
4997
"linkedin_url": linkedin_url,
50-
"portfolio_link": portfolio_link or None,
98+
"portfolio_link": (portfolio_link or "").strip() or None,
5199
"resume": resume_path,
52100
})
53101
return {"ok": True, "id": row[0]["id"] if row else None}

backend/routes/newsletter.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import logging
2+
13
from fastapi import APIRouter, HTTPException
24
from pydantic import BaseModel, EmailStr, field_validator
35
from db.connection import table
46

7+
logger = logging.getLogger("newsletter")
8+
59
router = APIRouter()
610

711

@@ -24,6 +28,13 @@ def subscribe(body: SubscribeRequest):
2428
{"email": body.email},
2529
on_conflict="email",
2630
)
27-
except Exception as e:
28-
raise HTTPException(status_code=500, detail=str(e))
31+
except Exception:
32+
# #199: never echo the raw DB/PostgREST exception text to the client —
33+
# it leaks internal detail (host/table/driver). Log the full error
34+
# server-side and return a generic message.
35+
logger.exception("newsletter subscribe failed")
36+
raise HTTPException(
37+
status_code=500,
38+
detail="Could not process subscription. Please try again later.",
39+
)
2940
return {"ok": True}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Regression tests for #199:
3+
- newsletter.subscribe must not leak raw exception text to the client.
4+
- careers.apply must bound resume size/type and validate basic inputs.
5+
6+
These assert the security properties that fail on pre-fix code (raw exception
7+
text echoed back; unbounded/untyped uploads accepted).
8+
"""
9+
from unittest.mock import patch
10+
11+
from fastapi.testclient import TestClient
12+
13+
from main import app
14+
15+
client = TestClient(app)
16+
17+
INTERNAL = "SECRET_DB_INTERNALS: connection refused at 10.0.0.5 table=newsletter_emails"
18+
19+
# Minimal valid form for /api/careers/apply.
20+
VALID_FORM = {
21+
"position": "Software Engineer",
22+
"full_name": "Ada Lovelace",
23+
"email": "ada@example.com",
24+
"linkedin_url": "https://www.linkedin.com/in/ada",
25+
}
26+
27+
28+
class TestNewsletterErrorLeak:
29+
def test_db_error_returns_generic_message_not_raw_exception(self):
30+
with patch("routes.newsletter.table") as t:
31+
t.return_value.upsert.side_effect = Exception(INTERNAL)
32+
r = client.post("/api/newsletter/subscribe", json={"email": "x@example.com"})
33+
34+
assert r.status_code == 500
35+
detail = r.json().get("detail", "")
36+
# Pre-fix: detail == str(e) and contains INTERNAL. Post-fix: generic.
37+
assert INTERNAL not in detail
38+
assert "10.0.0.5" not in detail
39+
assert detail == "Could not process subscription. Please try again later."
40+
41+
42+
class TestCareersUploadBounds:
43+
def test_oversize_resume_rejected_413(self):
44+
big = b"%PDF-" + b"0" * (5 * 1024 * 1024 + 1)
45+
with patch("routes.careers.httpx") as hx, patch("routes.careers.table") as t:
46+
t.return_value.insert.return_value = [{"id": "app1"}]
47+
r = client.post(
48+
"/api/careers/apply",
49+
data=VALID_FORM,
50+
files={"resume": ("resume.pdf", big, "application/pdf")},
51+
)
52+
# Pre-fix accepted it (200, unbounded read + upload); fix returns 413
53+
# and never reaches the storage upload.
54+
assert r.status_code == 413
55+
hx.put.assert_not_called()
56+
57+
def test_wrong_content_type_rejected_415(self):
58+
with patch("routes.careers.httpx") as hx, patch("routes.careers.table") as t:
59+
t.return_value.insert.return_value = [{"id": "app1"}]
60+
r = client.post(
61+
"/api/careers/apply",
62+
data=VALID_FORM,
63+
files={"resume": ("resume.exe", b"MZ\x90\x00", "application/octet-stream")},
64+
)
65+
assert r.status_code == 415
66+
hx.put.assert_not_called()
67+
68+
def test_invalid_email_rejected_422(self):
69+
with patch("routes.careers.httpx"), patch("routes.careers.table") as t:
70+
t.return_value.insert.return_value = [{"id": "app1"}]
71+
r = client.post(
72+
"/api/careers/apply",
73+
data={**VALID_FORM, "email": "not-an-email"},
74+
)
75+
assert r.status_code == 422
76+
77+
def test_valid_application_succeeds(self):
78+
with patch("routes.careers.httpx") as hx, patch("routes.careers.table") as t:
79+
hx.put.return_value.raise_for_status.return_value = None
80+
t.return_value.insert.return_value = [{"id": "app1"}]
81+
r = client.post(
82+
"/api/careers/apply",
83+
data=VALID_FORM,
84+
files={"resume": ("resume.pdf", b"%PDF-1.4 small", "application/pdf")},
85+
)
86+
assert r.status_code == 200
87+
assert r.json() == {"ok": True, "id": "app1"}

0 commit comments

Comments
 (0)