Hardening A/2: close the open routes — decorator order, newsletter relay, request edit link, upload directories, hacker/team PII, deposit amount - #290
Conversation
…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) |
75cd2de to
d3304be
Compare
…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>
|
CodeQL note (for the reviewer): the one remaining "reflected XSS" alert points at 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). |
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 PRhardening/1-security-prepis the companion and is safe to deploy in either order.What was open, what changed
develop)GET /api/messages/hackathon/<event>/<type>/checkins@auth.*sat above@bp.route, so Flask registered the undecorated function; returned full volunteer docs (email, phone, dietary, Slack ids)@bp.routeoutermost → 401/403 as intended. Only caller is the admin check-in workbench, which already sends Bearer +X-Org-IdPATCH /api/problem-statements/eventsX-Org-Id(FE PR-1)POST /api/newsletter/send_newsletter,preview_newsletter,GET /<user_id>init_authvolunteer.admin-gated viacommon.auth;POST /<subscribe>/<doc_id>stays per-user on purpose. No frontend caller existsPATCH /api/messages/create-hackathon/<id>(anonymous capability link)doc.update(json)(could setstatus), emailed the BODY'scontactEmailbefore checking the doc existed,None→ 500HACKATHON_REQUEST_EDITABLE_FIELDS, lockstep-tested against the frontend form), 404 before any email, confirmation goes to the STORED contact,adminNotesstripped from the public GET, ids areuuid4POST /api/messages/upload-imageteams/<id>/only; any logged-in user could still write (and overwrite)ohack.dev/logos, event galleries, nonprofit logos… No body size capauthorize_upload_directory: non-admins → application photo dirs,images, or their event'shackathons/<e>/planning/…(plan editors); admins anywhere;..→ 400; non-admins can't overwrite (409file_exists).MAX_CONTENT_LENGTH32 MiB with a JSON 413GET /api/hacker/applications/<event>optional_user; 4-key denylist leaked phone, deposit fields,sent_emails, free textrequire_user; projected toHACKER_DIRECTORY_FIELDS(every keyfindteam.jsreads +teamCode+isSelected)/api/messages/team/<id>,/teams, event payload,/api/team/<event>/me,GET /api/team/<hackathon_id>)admin_notes,nonprofit_rankings,comments,communication_history; the hackathon list route also shipped full member user docs (email) to any logged-in userpublic_team_viewstrips the internals everywhere; non-admins get slim member profiles on the list route; admins keep full payloads; new admin-onlyGET /api/team/admin/<teamid>returns the full doc (the frontend prefers it, falls back on 404).mentor_*stays public by designcheckout.session.completedaspaid, never comparing the amountdefault_amount_centsBEFORE the already-paid shortcut →underpaid+ Slack audit; missing config fails open. Best-effort (PaymentIntent verification is the deferred follow-up)GET /news?limit===compares; unset env var matchedNone;int()crash on bad limithmac.compare_digest, unset never matches; 400 on non-int, clamped 1..200Tests (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 withoutX-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 inFIREBASE_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
X-Org-Idto the problem-statement link request and the admin team route fallback). Everything here is otherwise backwards compatible with the current frontend.except Exceptionwrappers that swallowRateLimitException.Test plan (staging, then prod)
GET /api/messages/hackathon/<event>/hacker/checkins→ 401;/admin/hackathons/<event>?section=checkinstill loads counts + list as an admin.POST /api/newsletter/send_newsletter→ 401./hack/request/<id>: edit + save works;PATCHwith{"status":"approved"}leaves status unchanged; a made-up id → 404 (was 500).POST /api/messages/upload-imagewithdirectory=ohack.dev/logos→ 403;directory=hackers→ 200; the hacker application photo upload and the team dashboard project image upload still work;/hack/<event>/planattachment as a plan editor works, as an admin works.GET /api/hacker/applications/<event>anonymous → 401; authed → objects have nophone;/hack/<event>/findteamrenders profiles unchanged.GET /api/messages/team/<id>→ noadmin_notes; non-adminGET /api/team/<event>→ noadmin_notes, members withoutemail_address;/admin/hackathons/<event>?section=teams→ open a team → admin notes + nonprofit rankings still show (admin route)./judge/<event>/team/<id>and the event page#teamsgallery render as before (mentor fields intact).GET /api/messages/news?limit=abc→ 400;?limit=99999→ 200 items max.🤖 Generated with Claude Code