Skip to content

Hardening A/2: close the open routes — decorator order, newsletter relay, request edit link, upload directories, hacker/team PII, deposit amount - #290

Open
gregv wants to merge 8 commits into
developfrom
hardening/a-security
Open

gregv wants to merge 8 commits into
developfrom
hardening/a-security

Conversation

@gregv

@gregv gregv commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

First of two stacked backend PRs from the hardening plan (frontend-ohack.dev/docs/plans/hardening-security-seo-reliability-2026-09.md, reconciled against #288/#289). This one closes the open doors found in the audit. Every change landed test-first: each new test was run, seen failing for the documented reason, then the fix landed. Every affected frontend caller was traced (headers sent, fields read) before gating anything; frontend PR hardening/1-security-prep is the companion and is safe to deploy in either order.

What was open, what changed

Route / area Before (verified on develop) After
GET /api/messages/hackathon/<event>/<type>/checkins Public. @auth.* sat above @bp.route, so Flask registered the undecorated function; returned full volunteer docs (email, phone, dietary, Slack ids) @bp.route outermost → 401/403 as intended. Only caller is the admin check-in workbench, which already sends Bearer + X-Org-Id
PATCH /api/problem-statements/events Same decorator bug → unauthenticated re-linking of problem statements Gated. The frontend hook now sends X-Org-Id (FE PR-1)
POST /api/newsletter/send_newsletter, preview_newsletter, GET /<user_id> Auth commented out → open Gmail relay (arbitrary HTML to arbitrary addresses); module built its own init_auth volunteer.admin-gated via common.auth; POST /<subscribe>/<doc_id> stays per-user on purpose. No frontend caller exists
PATCH /api/messages/create-hackathon/<id> (anonymous capability link) Raw doc.update(json) (could set status), emailed the BODY's contactEmail before checking the doc existed, None → 500 Body filtered to the 26 form keys (HACKATHON_REQUEST_EDITABLE_FIELDS, lockstep-tested against the frontend form), 404 before any email, confirmation goes to the STORED contact, adminNotes stripped from the public GET, ids are uuid4
POST /api/messages/upload-image #289 gated teams/<id>/ only; any logged-in user could still write (and overwrite) ohack.dev/logos, event galleries, nonprofit logos… No body size cap One gate authorize_upload_directory: non-admins → application photo dirs, images, or their event's hackathons/<e>/planning/… (plan editors); admins anywhere; .. → 400; non-admins can't overwrite (409 file_exists). MAX_CONTENT_LENGTH 32 MiB with a JSON 413
GET /api/hacker/applications/<event> optional_user; 4-key denylist leaked phone, deposit fields, sent_emails, free text require_user; projected to HACKER_DIRECTORY_FIELDS (every key findteam.js reads + teamCode + isSelected)
Team payloads (/api/messages/team/<id>, /teams, event payload, /api/team/<event>/me, GET /api/team/<hackathon_id>) Shipped admin_notes, nonprofit_rankings, comments, communication_history; the hackathon list route also shipped full member user docs (email) to any logged-in user public_team_view strips the internals everywhere; non-admins get slim member profiles on the list route; admins keep full payloads; new admin-only GET /api/team/admin/<teamid> returns the full doc (the frontend prefers it, falls back on 404). mentor_* stays public by design
Deposit webhook Marked any checkout.session.completed as paid, never comparing the amount Compares with the event's default_amount_cents BEFORE the already-paid shortcut → underpaid + Slack audit; missing config fails open. Best-effort (PaymentIntent verification is the deferred follow-up)
Shared secrets / GET /news?limit= == compares; unset env var matched None; int() crash on bad limit hmac.compare_digest, unset never matches; 400 on non-int, clamped 1..200

Tests (all new, all seen red first)

test/common/test_view_decorator_order.py (AST guard — named exactly the two broken functions), test/common/auth_stubs.py (rejecting stub: 401 without Bearer, 403 without X-Org-Id), api/messages/tests/test_route_gates.py, test_hackathon_requests.py (+7), test_upload_image_gate.py (rewritten cases + planning-editor case), test_upload_overwrite.py, test/common/test_payload_too_large.py, api/volunteers/tests/test_hacker_applications_projection.py, test_deposit_webhook_amount.py, api/teams/tests/test_public_team_projection.py.

Green locally (run per directory with ENVIRONMENT=test, EMPTY Slack/Resend vars and a throwaway service-account JSON in FIREBASE_CERT_CONFIG): messages (each file), volunteers, teams, judging, peer_votes, submissions, test/common. CI still does not run pytest — that lands in the stacked PR-B.

Deploy notes

  • Deploy after frontend PR-1 (it adds X-Org-Id to the problem-statement link request and the admin team route fallback). Everything here is otherwise backwards compatible with the current frontend.
  • Non-admin planning editors: attachments now upload (they were 401 for lack of a Bearer, then would have been 403); same-name re-attach by a non-admin gets 409 (planning filenames aren't timestamped) — admins can overwrite.
  • Follow-ups tracked in the plan: PaymentIntent verification on submit, per-IP rate limiting, except Exception wrappers that swallow RateLimitException.

Test plan (staging, then prod)

  1. Anonymous GET /api/messages/hackathon/<event>/hacker/checkins → 401; /admin/hackathons/<event>?section=checkin still loads counts + list as an admin.
  2. Anonymous POST /api/newsletter/send_newsletter → 401.
  3. From a hackathon-request email link /hack/request/<id>: edit + save works; PATCH with {"status":"approved"} leaves status unchanged; a made-up id → 404 (was 500).
  4. As a logged-in non-admin: POST /api/messages/upload-image with directory=ohack.dev/logos → 403; directory=hackers → 200; the hacker application photo upload and the team dashboard project image upload still work; /hack/<event>/plan attachment as a plan editor works, as an admin works.
  5. GET /api/hacker/applications/<event> anonymous → 401; authed → objects have no phone; /hack/<event>/findteam renders profiles unchanged.
  6. GET /api/messages/team/<id> → no admin_notes; non-admin GET /api/team/<event> → no admin_notes, members without email_address; /admin/hackathons/<event>?section=teams → open a team → admin notes + nonprofit rankings still show (admin route).
  7. /judge/<event>/team/<id> and the event page #teams gallery render as before (mentor fields intact).
  8. GET /api/messages/news?limit=abc → 400; ?limit=99999 → 200 items max.

🤖 Generated with Claude Code

gregv and others added 6 commits September 30, 2026 18:21
…elay, constant-time tokens, JSON 413

Two routes were public: @auth.* sat ABOVE @bp.route on
GET /hackathon/<event>/<type>/checkins (full volunteer docs incl. email/phone)
and PATCH /api/problem-statements/events, so Flask registered the undecorated
function. @bp.route is now outermost; test/common/test_view_decorator_order.py
AST-scans every *_views.py and fails on a repeat (it named exactly these two).

api/newsletters: send_newsletter / preview_newsletter / GET /<user_id> had auth
commented out (open Gmail relay) and the module built its own init_auth (not
importable under test). They are volunteer.admin-gated via common.auth now;
POST /<subscribe>/<doc_id> stays per-user on purpose.

Route-level proof uses the new rejecting auth stub (test/common/auth_stubs.py):
no Authorization -> 401, no X-Org-Id -> 403 — the pass-through stub cannot see a
dropped decorator. api/messages/tests/test_route_gates.py covers checkins,
problem-statement link, newsletter, the news/praise X-Api-Key checks
(hmac.compare_digest; unset env never matches) and GET /news?limit= (400 on
non-int, clamped 1..200). The create-hackathon views map None -> 404.

MAX_CONTENT_LENGTH = 32 MiB with a JSON 413 handler (the app forces JSON
content-type and frontend callers do res.json()).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…te, planning-editor allowance

#289 gated teams/<id>/ only; any logged-in user could still write — and
overwrite — ohack.dev/logos, hackathons/<e>/photos, nonprofits, news/...
authorize_upload_directory (api/submissions/submissions_service.py) is now the
single gate the view calls: teams/... delegates to the existing membership
gate; non-admins may otherwise write only the single-segment application
photo directories (images hackers volunteers mentors judges sponsors uploads)
or hackathons/<event>/planning/... when can_write_plan_for_event admits them
(card attachments); admins anywhere; '..' or odd characters -> 400.

upload_image_to_cdn(request, allow_overwrite=False) answers 409 file_exists
for non-admins via the new cdn.blob_exists() — upload_to_cdn itself still
overwrites because certificates/hearts/openai rely on that.

Tests: test_upload_image_gate.py (403 for shared dirs — was 200; planning
editor case), test_upload_overwrite.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…mail, confirm to the stored contact

PATCH /api/messages/create-hackathon/<id> is the anonymous requester's edit
link (public by design). It did doc.update(json) with no allowlist (status was
settable) and emailed the BODY's contactEmail before checking the doc existed
(missing doc -> 500). Now: body filtered to HACKATHON_REQUEST_EDITABLE_FIELDS
(= the frontend HackathonRequestForm formData keys, lockstep-tested), None
when the doc is missing (view -> 404) before any email, confirmation to the
STORED contact, adminNotes stripped from the public GET and PATCH responses,
new ids uuid4. admin_update_hackathon_request is untouched.

Tests: api/messages/tests/test_hackathon_requests.py (+7).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ds; admin-only full-doc route

admin_notes, nonprofit_rankings, comments and communication_history shipped on
GET /api/messages/team/<id>, /teams, the unauthenticated event payload,
/api/team/<event>/me and GET /api/team/<hackathon_id> — the last one also
returned full member user docs (email_address) to any logged-in user.

services/teams_service.py::public_team_view strips PUBLIC_TEAM_STRIPPED_FIELDS
in every public/member getter; the hackathon list route trims member docs to
{id,user_id,name,nickname,profile_image} for non-admins and keeps the full
payload for admins (TeamManagement, judging admin views). New
GET /api/team/admin/<teamid> (volunteer.admin) returns the full doc; the
frontend's adminTeamApi prefers it and falls back on 404. mentor_* fields are
public by design and untouched.

Tests: api/teams/tests/test_public_team_projection.py (11).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…never marks an underpaid session paid

GET /api/hacker/applications/<event> was optional_user with a 4-key denylist,
leaking phone, deposit bookkeeping, sent_emails and free-text answers for every
applicant. It is require_user and projected to HACKER_DIRECTORY_FIELDS — every
key findteam.js reads, plus teamCode and isSelected (peer votes).

_handle_checkout_session_completed compares amount_total with the event's
default_amount_cents BEFORE the already-paid shortcut; short -> deposit_status
'underpaid' + Slack audit, never 'paid'; missing config / lookup error fails
open. Best-effort — PaymentIntent verification on submit is the follow-up.

Tests: test_hacker_applications_projection.py, test_deposit_webhook_amount.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
if blocked:
return blocked
return upload_image_to_cdn(request)
return upload_image_to_cdn(request, allow_overwrite=admin)
Comment thread test/common/test_payload_too_large.py Fixed
gregv and others added 2 commits September 30, 2026 15:12
…est route no longer echoes form keys

Both were CodeQL 'reflected XSS' findings on the PR. Flask already
serialises returned dicts as JSON and the payloads are constant strings, so
neither was exploitable — making the JSON explicit (and not echoing request
data in a test-only route) removes the taint path the analyser follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CodeQL kept following request.form['directory'] through the gate's return
value into the response even after jsonify(). The gate still returns
(payload, status) for its own tests; the view now maps the error code to a
constant response, so no request-derived object is ever returned.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@gregv

gregv commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

CodeQL note (for the reviewer): the one remaining "reflected XSS" alert points at return upload_image_to_cdn(request, allow_overwrite=admin) in messages_views.py. That line pre-dates this PR (only the allow_overwrite= argument was added) and the response is a JSON dict ({"success": true, "url": ...}) — the app's after_request forces Content-Type: application/json on every response, so nothing is rendered as HTML. It is the same false-positive class #288 documented ("Flask serializes the returned dicts as JSON"). The two alerts CodeQL raised on genuinely new code in this PR (the gate's error return, the 413 test route) were fixed by answering from constant response tables. Recommend dismissing this last one as a false positive.

All backend checks otherwise green: PYLint ✅, the new Tests job ✅ (554 tests, lands in #291 and already runs on this branch's workflow file when #291 is stacked).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants