From 61622d2db6a1b410955cdb83ca5c2c18a6b17cfc Mon Sep 17 00:00:00 2001 From: aamoghS Date: Sat, 8 Aug 2026 18:52:19 -0700 Subject: [PATCH 1/2] chore: cleanup, and the decisions that were left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The second sanitizer is gone.** `sanitizeInput` stripped markup, truncated every string at 10,000 characters and rejected any prose containing SQL keywords — "select a track from the list" among them. Nothing called it: the request path runs `scrubMarkup`, which refuses executable markup and passes everything else through byte for byte. Only the tests called it, so the suite was describing behaviour the product did not have. They now target the real one. **`publicProcedure` requires a database.** Without it a public query with no connection fell through to its own `if (!db)` branch, which returns null — and the funnel page renders that as "nothing announced yet". A misconfigured deploy looked like an empty product instead of a broken one. **Cache invalidation actually evicts.** `deletePattern` anchors with `$`, so the wildcard-free entries matched no real key — and because the map HAS an entry, the namespace fallback was skipped, so nothing at all was evicted. Also adds the caller's own keys to events.checkIn, so a member who just scanned sees the change. **Security logging goes to stderr**, not to a file under the source tree that does not exist in the container, where the ENOENT was swallowed by an empty callback — the one message saying security logging had stopped was itself silently dropped. Dead exports removed with it. Decisions, all recorded in PLAN.md §5: - **D2** — registration is an application. `approved` to form a team, `checked_in` to submit: acceptance is a decision made weeks earlier and says nothing about whether somebody turned up, and judging is in person against a table number. Every participant is created `pending` and `pending` used to pass, so teams formed and projects were submitted with no review at all. - **D4** — points removed. Every club check-in carried the schema default and the attendee CSV exported that constant as a "Points" column: a fabricated number presented as data. - **D5** — a `not_accepted` audience, excluded from every other one so it can only be sent on purpose, with a drafted message prefilled into an untouched form. - **D7** — the security headers ship report-only until CSP_ENFORCE=true, with /api/csp-report collecting violations; the 238-line module nothing imported is deleted. `camera` is deliberately absent from Permissions-Policy: /scan reads QR codes through getUserMedia. - **D9** — /docs is linked from the admin nav. - **W22** — initiative decisions are emailed. People applied and heard nothing. Database: packages/db/ddl/2026-08-08-drop-points.sql (D4). Verified: typecheck, 420 tests, lint --max-warnings 0, build. --- apphosting.yaml | 22 +- firebase.json | 14 - .../src/.internal-tests/announcements.test.ts | 37 +++ .../.internal-tests/hackathon-flow.test.ts | 29 +- .../.internal-tests/participant-edge.test.ts | 71 ++++- .../src/.internal-tests/resilience.test.ts | 27 +- .../api/src/.internal-tests/routers.test.ts | 20 +- .../api/src/.internal-tests/security.test.ts | 286 +++++++++-------- packages/api/src/middleware/cache.ts | 42 ++- packages/api/src/middleware/http-security.ts | 237 -------------- packages/api/src/middleware/security.ts | 299 +++++++----------- packages/api/src/root.ts | 2 - .../api/src/routers/hackathon/announce.ts | 21 +- packages/api/src/routers/hackathon/events.ts | 3 - packages/api/src/routers/hello-procedures.ts | 39 --- packages/api/src/routers/hello.ts | 9 - packages/api/src/routers/initiative.ts | 84 ++++- packages/api/src/routers/member.ts | 15 +- packages/api/src/routers/team.ts | 51 ++- packages/api/src/trpc.ts | 45 ++- packages/db/ddl/2026-08-08-drop-points.sql | 21 ++ packages/db/src/schemas/events.ts | 2 - packages/db/src/schemas/hackathons.ts | 11 +- .../app/(portal)/admin/attendees/page.tsx | 5 +- sites/mainweb/app/(portal)/submit/page.tsx | 16 + sites/mainweb/app/api/csp-report/route.ts | 43 +++ .../admin/hackathons/AnnouncementsTab.tsx | 36 ++- .../components/admin/hackathons/EventsTab.tsx | 29 -- .../admin/hackathons/HackathonCard.tsx | 5 - .../components/hackathon/ScheduleTab.tsx | 19 +- .../components/portal/PortalSidebar.tsx | 8 +- sites/mainweb/next.config.mjs | 57 +++- 32 files changed, 837 insertions(+), 768 deletions(-) delete mode 100644 packages/api/src/middleware/http-security.ts delete mode 100644 packages/api/src/routers/hello-procedures.ts delete mode 100644 packages/api/src/routers/hello.ts create mode 100644 packages/db/ddl/2026-08-08-drop-points.sql create mode 100644 sites/mainweb/app/api/csp-report/route.ts diff --git a/apphosting.yaml b/apphosting.yaml index 9d2fad38..6d427961 100644 --- a/apphosting.yaml +++ b/apphosting.yaml @@ -1,4 +1,5 @@ -# Monorepo - app is in sites/portal +# Monorepo — the deployed app is sites/mainweb (workspace name: web). +# There is no sites/portal; the portal is a route group inside mainweb. scripts: buildCommand: pnpm install && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true) @@ -27,6 +28,15 @@ env: secret: AUTH_GOOGLE_ID - variable: GOOGLE_CLIENT_SECRET secret: AUTH_GOOGLE_SECRET + # GitHub sign-in. The provider is only registered when BOTH of these are + # present (packages/auth/src/config.ts), so until the secrets exist the + # login page hides the button rather than offering one that cannot work. + # Callback URL to register on the GitHub OAuth app: + # https://datasciencegt.org/api/auth/callback/github + - variable: GITHUB_CLIENT_ID + secret: AUTH_GITHUB_ID + - variable: GITHUB_CLIENT_SECRET + secret: AUTH_GITHUB_SECRET - variable: AUTH_URL value: https://datasciencegt.org - variable: NEXTAUTH_URL @@ -49,8 +59,6 @@ env: secret: projects/672446353769/secrets/EMAIL_SERVER_PASSWORD - variable: EMAIL_FROM value: datascience.gt@gmail.com - - variable: CRON_SECRET - secret: CRON_SECRET # Flood-protection thresholds, sized per instance for a full venue. # These are the ceiling for one signed-in person, not for the building — # the limiter keys on user id when somebody is signed in. The short block @@ -64,3 +72,11 @@ env: value: "14000" - variable: DDOS_BLOCK_DURATION_MS value: "30000" + # Proxies between the client and this process that append to + # X-Forwarded-For. Set explicitly rather than left to the code default so + # the value is reviewable here. Cloud Run behind Google's load balancer is + # 1; if a CDN is added in front, this becomes 2 — and getting it wrong is + # silent. The process logs the observed header shape once at startup + # ("[Security] x-forwarded-for has N entries"); expect hops = entries - 1. + - variable: TRUSTED_PROXY_HOPS + value: "1" diff --git a/firebase.json b/firebase.json index 2f353be7..2bd09047 100644 --- a/firebase.json +++ b/firebase.json @@ -1,19 +1,5 @@ { "hosting": [ - { - "site": "dsgt-portal", - "public": "sites/portal/public", - "cleanUrls": true, - "rewrites": [ - { - "source": "**", - "run": { - "serviceId": "portal", - "region": "us-central1" - } - } - ] - }, { "site": "dsgt-website", "public": "sites/mainweb/out", diff --git a/packages/api/src/.internal-tests/announcements.test.ts b/packages/api/src/.internal-tests/announcements.test.ts index c6250d01..566fbcce 100644 --- a/packages/api/src/.internal-tests/announcements.test.ts +++ b/packages/api/src/.internal-tests/announcements.test.ts @@ -224,6 +224,43 @@ describe("Announcements", () => { ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); + /** + * Rejected and waitlisted applicants are excluded from every other + * audience, which left nothing in the product able to reach them at all. + * This audience exists to be chosen deliberately, for a message written + * for it — so it must be selectable, and must not leak into the others. + */ + it("accepts the not_accepted audience", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + mockSelectRows.mockReturnValue([ + { userId: "u1", email: "turned.down@example.com" }, + ]); + + const res = await callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + audience: "not_accepted", + subject: "An update on your application", + }); + + expect(res.totalRecipients).toBe(1); + const announcementRow = mockInsert.mock.calls + .map((c) => c[2]?.[0]) + .find((row) => row && !Array.isArray(row) && "audience" in row); + expect(announcementRow.audience).toBe("not_accepted"); + }); + + it("refuses an audience name that does not exist", async () => { + asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined)); + + await expect( + callerFor(ADMIN).hackathon.createAnnouncement({ + ...compose, + // @ts-expect-error deliberately not one of the five audiences + audience: "everyone", + }), + ).rejects.toBeDefined(); + }); + }); describe("Sending in batches", () => { diff --git a/packages/api/src/.internal-tests/hackathon-flow.test.ts b/packages/api/src/.internal-tests/hackathon-flow.test.ts index 9467217c..fea28497 100644 --- a/packages/api/src/.internal-tests/hackathon-flow.test.ts +++ b/packages/api/src/.internal-tests/hackathon-flow.test.ts @@ -557,7 +557,13 @@ describe("Hackathon end-to-end flow", () => { mockFindFirst.mockImplementation((table) => { if (table === "hackathons") return hackathonStartedHoursAgo(hoursIn); if (table === "hackathonParticipants") - return participant ?? { id: PARTICIPANT, teamId: null }; + return ( + participant ?? { + id: PARTICIPANT, + teamId: null, + registrationStatus: "checked_in", + } + ); if (table === "hackathonTeams") return { id: "team_1", @@ -653,7 +659,13 @@ describe("Hackathon end-to-end flow", () => { hackingStartTime: new Date(Date.now() - 20 * HOUR), }; if (table === "hackathonParticipants") - return opts.participant ?? { id: PARTICIPANT, teamId: null }; + return ( + opts.participant ?? { + id: PARTICIPANT, + teamId: null, + registrationStatus: "approved", + } + ); if (table === "hackathonTeams") return ( opts.team ?? { @@ -680,7 +692,11 @@ describe("Hackathon end-to-end flow", () => { it("refuses to let someone join two teams", async () => { const caller = joinCaller({ - participant: { id: PARTICIPANT, teamId: "team_existing" }, + participant: { + id: PARTICIPANT, + teamId: "team_existing", + registrationStatus: "approved", + }, }); await expect(caller.team.joinTeam(join)).rejects.toThrow( @@ -749,7 +765,12 @@ describe("Hackathon end-to-end flow", () => { hackingStartTime: new Date(Date.now() - hoursIn * HOUR), }; if (table === "hackathonParticipants") - return { id: PARTICIPANT, teamId: "team_1" }; + // Submitting requires the badge scan — see checkCheckedIn. + return { + id: PARTICIPANT, + teamId: "team_1", + registrationStatus: "checked_in", + }; if (table === "hackathonProjects") return existingProject ? { id: "project_1" } : undefined; return undefined; diff --git a/packages/api/src/.internal-tests/participant-edge.test.ts b/packages/api/src/.internal-tests/participant-edge.test.ts index aaae7b0a..7389fbcc 100644 --- a/packages/api/src/.internal-tests/participant-edge.test.ts +++ b/packages/api/src/.internal-tests/participant-edge.test.ts @@ -319,7 +319,10 @@ describe("Participant edge cases", () => { hackathonId: HACK_A, userId: "user_a", teamId: TEAM_A, - registrationStatus: opts.status ?? "approved", + // Submitting requires the badge scan, so "checked in" is the + // default state for these tests — they are about ownership and the + // clock, not about admission. + registrationStatus: opts.status ?? "checked_in", team: { id: TEAM_A, captainId: opts.captainId ?? "captain_user" }, }; if (table === "hackathonTeams") @@ -442,7 +445,15 @@ describe("Participant edge cases", () => { hackingStartTime: new Date(FIXED.getTime() - offsetMs), }; if (table === "hackathonParticipants") - return participant ?? { id: PARTICIPANT_A, teamId: null }; + // Checked in by default: submitting requires it and forming a team + // requires acceptance; these tests are about the clock. + return ( + participant ?? { + id: PARTICIPANT_A, + teamId: null, + registrationStatus: "checked_in", + } + ); if (table === "hackathonTeams") return { id: TEAM_A, @@ -654,6 +665,62 @@ describe("Participant edge cases", () => { ).rejects.toThrow(new RegExp(status)); }, ); + + /** + * Registration is an application, not admission. + * + * Every participant is created `pending`, and `pending` used to pass — so + * teams formed and projects were submitted with no review having happened, + * while organisers were shown an approve/reject screen that decided nothing + * but an email. + */ + it("keeps a pending applicant out until they are accepted", async () => { + const caller = rejectedApplicant("pending"); + + await expect( + caller.team.createTeam({ + hackathonId: HACK_A, + name: "meow", + maxMembers: 4, + }), + ).rejects.toThrow(/still being reviewed/i); + await expect( + caller.team.joinTeam({ hackathonId: HACK_A, teamId: TEAM_A }), + ).rejects.toThrow(/still being reviewed/i); + }); + + it("lets an accepted applicant form a team", async () => { + const caller = rejectedApplicant("approved"); + + await expect( + caller.team.createTeam({ + hackathonId: HACK_A, + name: "meow", + maxMembers: 4, + }), + ).resolves.toMatchObject({ id: TEAM_A }); + }); + + /** + * Acceptance is a decision made weeks earlier; it says nothing about + * whether somebody turned up. Judging is in person against a table number, + * so a submission has to come from a team that is actually in the building. + */ + it("refuses a submission from someone accepted but not checked in", async () => { + const caller = rejectedApplicant("approved"); + + await expect(caller.team.submitProject(projectInput())).rejects.toThrow( + /check in at the event/i, + ); + }); + + it("accepts a submission once they have been scanned in", async () => { + const caller = rejectedApplicant("checked_in"); + + await expect( + caller.team.submitProject(projectInput()), + ).resolves.toBeDefined(); + }); }); // ===================================================================== diff --git a/packages/api/src/.internal-tests/resilience.test.ts b/packages/api/src/.internal-tests/resilience.test.ts index cd8676ac..145accd4 100644 --- a/packages/api/src/.internal-tests/resilience.test.ts +++ b/packages/api/src/.internal-tests/resilience.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import { sanitizeInput } from "../middleware/security"; +// The sanitizer the request path actually runs — see the note in +// security.test.ts about the second, uncalled implementation these used to +// target. +import { scrubMarkup } from "../trpc"; vi.mock("@query/db", () => ({ db: {}, @@ -33,22 +36,28 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => { const zalgo = "H\u033d\u0310\u0355e\u033d\u0310\u0355l\u033d\u0310\u0355l\u033d\u0310\u0355o\u033d\u0310\u0355"; - expect(typeof sanitizeInput(zalgo)).toBe("string"); + expect(typeof scrubMarkup(zalgo)).toBe("string"); // Backtracking on this input would take seconds, not 250ms. - expect(fastestRun(() => sanitizeInput(zalgo))).toBeLessThan(250); + expect(fastestRun(() => scrubMarkup(zalgo))).toBeLessThan(250); }); it("should handle massive combined character strings efficiently", () => { const hugeZalgo = "A" + "\u0301".repeat(5000); - expect(typeof sanitizeInput(hugeZalgo)).toBe("string"); - expect(fastestRun(() => sanitizeInput(hugeZalgo))).toBeLessThan(500); + expect(typeof scrubMarkup(hugeZalgo)).toBe("string"); + expect(fastestRun(() => scrubMarkup(hugeZalgo))).toBeLessThan(500); }); - it("should handle long plain strings up to the maximum slice length", () => { + /** + * Long input is passed through, not truncated. Silently cutting a 15,000 + * character project description at 10,000 is the same class of bug as + * rewriting markup: the author is never told, and the loss is permanent. + * Length limits belong in each procedure's own schema, where the error can + * name the field. + */ + it("passes a long plain string through untouched", () => { const normalLongString = "b".repeat(15000); - const result = sanitizeInput(normalLongString) as string; - expect(result.length).toBe(10000); // Truncation limit + expect(scrubMarkup(normalLongString)).toBe(normalLongString); }); }); @@ -70,7 +79,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => { it("should handle double file extensions safely without modifications", () => { const name = "document.pdf.png"; - const result = sanitizeInput(name); + const result = scrubMarkup(name); expect(result).toBe("document.pdf.png"); }); }); diff --git a/packages/api/src/.internal-tests/routers.test.ts b/packages/api/src/.internal-tests/routers.test.ts index 5af01b77..7588017e 100644 --- a/packages/api/src/.internal-tests/routers.test.ts +++ b/packages/api/src/.internal-tests/routers.test.ts @@ -5,7 +5,7 @@ import { TRPCError } from "@trpc/server"; import { cache } from "../middleware/cache"; import { db } from "@query/db"; import { errorFormatter } from "../trpc"; -import { sanitizeInput } from "../middleware/security"; +import { scrubMarkup } from "../trpc"; // Fully mock the DB at the file level const mockFindFirst = vi.fn(); @@ -644,12 +644,11 @@ describe("Router Integration and Access Control Verification Suite", () => { }); it("should ensure backslash escapes in sql queries are checked securely", () => { - // Drizzle handles parameterization automatically, so raw inputs are never - // interpolated directly. We test that inputs containing backslashes are - // sanitized/passed as single literals. + // Drizzle parameterises every query, so raw input is never interpolated. + // The sanitizer therefore passes this through byte for byte rather than + // guessing at SQL — guessing rejects ordinary prose. const dangerousValue = "value\\' OR \\'1\\'=\\'1"; - const cleanValue = sanitizeInput(dangerousValue); - expect(typeof cleanValue).toBe("string"); + expect(scrubMarkup(dangerousValue)).toBe(dangerousValue); }); }); @@ -706,6 +705,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { @@ -748,6 +750,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { @@ -799,6 +804,9 @@ describe("Router Integration and Access Control Verification Suite", () => { userId: "captain_user_id", hackathonId, teamId, + // Submitting requires the badge scan; these tests are about the + // window, so admission is deliberately out of the way. + registrationStatus: "checked_in", }; } if (table === "hackathons") { diff --git a/packages/api/src/.internal-tests/security.test.ts b/packages/api/src/.internal-tests/security.test.ts index c2068ec3..aaaeab61 100644 --- a/packages/api/src/.internal-tests/security.test.ts +++ b/packages/api/src/.internal-tests/security.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi } from "vitest"; import { - sanitizeInput, validateRequestSize, rateLimit, ddosProtection, } from "../middleware/security"; +// The sanitizer the request path actually runs. These tests used to target +// `sanitizeInput` in middleware/security.ts — a second implementation with +// different semantics (it stripped markup rather than refusing it) and no +// caller anywhere, so the suite described behaviour the product did not have. +import { scrubMarkup } from "../trpc"; import { TRPCError } from "@trpc/server"; vi.mock("@query/db", () => ({ @@ -13,180 +17,147 @@ vi.mock("@query/db", () => ({ })); describe("Security and Protection Verification Suite", () => { - describe("1. Input Sanitization - XSS Vulnerability Protections", () => { - it("should drop script tags completely", () => { - const result = sanitizeInput('hello'); - expect(result).toBe("hello"); - }); - - it("should sanitize image tag onerror events", () => { - try { - const result = sanitizeInput( - '', - ); - expect(result).not.toContain("onerror"); - } catch (err) { - expect(err).toBeInstanceOf(TRPCError); - } - }); - - it("should sanitize svg onload actions", () => { - try { - const result = sanitizeInput(''); - expect(result).not.toContain("onload"); - expect(result).not.toContain("javascript"); - } catch (err) { - expect(err).toBeInstanceOf(TRPCError); - } - }); - - it("should block explicit javascript protocol references", () => { - const payload = 'javascript:alert("hacked")'; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should clean nested script evasion attempts", () => { - const result = sanitizeInput('<'); - expect(result).not.toContain(" { - const result = sanitizeInput( - "This is a text with < than and > than symbols.", - ); - expect(result).toBe( - "This is a text with < than and > than symbols.", + /** + * Dangerous markup is REJECTED, never rewritten: rewriting silently changes + * what somebody wrote, and an HTML parser over prose eats "loss` have to survive untouched. + */ + describe("1. Input sanitization — executable markup", () => { + it("refuses script tags rather than quietly removing them", () => { + expect(() => scrubMarkup('hello')).toThrow( + TRPCError, ); }); - }); - describe("2. Input Sanitization - SQL Injection Protections", () => { - it("should block classic union select injections", () => { - const payload = "1 UNION SELECT username, password FROM users"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a tag carrying an event handler", () => { + expect(() => + scrubMarkup(''), + ).toThrow(TRPCError); + expect(() => scrubMarkup('')).toThrow(TRPCError); }); - it("should block SQL query stacking comments", () => { - const payload = "DROP TABLE hackathons; -- "; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - - it("should block block-comment SQL injection style", () => { - const payload = "SELECT * FROM events /* check comments */ WHERE id = 1"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); + it("refuses a javascript: URI even as plain text", () => { + // Whoever renders this into an href gets an executable link. + expect(() => scrubMarkup('javascript:alert("hacked")')).toThrow( + TRPCError, + ); }); - it("should block case-insensitive SQL keywords combinations", () => { - const payload = "uNiOn SeLeCt secret FROM credentials"; - expect(() => sanitizeInput(payload)).toThrowError(TRPCError); - }); - }); - - describe("3. Input Sanitization - NoSQL Query Injection Protections", () => { - it("should filter out the where MongoDB operator", () => { - const payload = { $where: 'this.role == "admin"' }; - const result = sanitizeInput(payload) as Record; - expect(result.$where).toBeUndefined(); + it("refuses nested-tag evasion attempts", () => { + expect(() => scrubMarkup('<')).toThrow( + TRPCError, + ); }); - it("should filter out gt and lt MongoDB operators", () => { - const payload = { $gt: "0", $lt: "100", validKey: "data" }; - const result = sanitizeInput(payload) as Record; - expect(result.$gt).toBeUndefined(); - expect(result.$lt).toBeUndefined(); - expect(result.validKey).toBe("data"); + /** + * The case that makes rejection the right design: a hackathon is full of + * people writing comparisons and generics, and none of it may be mangled + * or bounced. + */ + it("passes ordinary prose and code through byte for byte", () => { + const prose = + "picks the class where loss a { - const payload = { $ne: "admin", $eq: "user", username: "guest" }; - const result = sanitizeInput(payload) as Record; - expect(result.$ne).toBeUndefined(); - expect(result.$eq).toBeUndefined(); - expect(result.username).toBe("guest"); + /** + * SQL is NOT guessed at. Parameterised queries already make it a non-issue, + * and pattern-matching prose for keywords rejects "select a track from the + * list" — a sentence somebody will genuinely write in a submission. + */ + it("does not reject prose that happens to read like SQL", () => { + const text = "select a track from the list, then update your project"; + expect(scrubMarkup(text)).toBe(text); }); }); - describe("4. Input Sanitization - Prototype Pollution Protections", () => { - it("should drop proto key assignments", () => { + describe("2. Input sanitization — object shape", () => { + it("drops prototype-polluting keys", () => { const payload = JSON.parse( - '{"__proto__": {"maliciousProperty": "injected"}}', + '{"__proto__": {"maliciousProperty": "injected"}, "name": "ok"}', ); - const result = sanitizeInput(payload) as Record; + const result = scrubMarkup(payload) as Record; expect(Object.prototype.hasOwnProperty.call(result, "__proto__")).toBe( false, ); - expect(({} as any).maliciousProperty).toBeUndefined(); + expect((({}) as Record).maliciousProperty).toBeUndefined(); + expect(result.name).toBe("ok"); }); - it("should drop constructor key assignments", () => { - const payload = JSON.parse( + it("drops constructor and prototype keys", () => { + const withConstructor = JSON.parse( '{"constructor": {"prototype": {"polluted": "yes"}}}', ); - const result = sanitizeInput(payload) as Record; - expect(Object.prototype.hasOwnProperty.call(result, "constructor")).toBe( - false, - ); - expect(({} as any).polluted).toBeUndefined(); - }); - - it("should drop prototype key assignments", () => { - const payload = { prototype: { admin: true }, username: "normal" }; - const result = sanitizeInput(payload) as Record; + expect( + Object.prototype.hasOwnProperty.call( + scrubMarkup(withConstructor) as object, + "constructor", + ), + ).toBe(false); + + const result = scrubMarkup({ + prototype: { admin: true }, + username: "normal", + }) as Record; expect(Object.prototype.hasOwnProperty.call(result, "prototype")).toBe( false, ); expect(result.username).toBe("normal"); }); - }); - describe("5. Input Sanitization - Complexity & Deep Nesting Limits", () => { - const makeNestedObject = (depth: number): any => { - if (depth === 0) return "leaf"; - return { node: makeNestedObject(depth - 1) }; - }; - - it("should allow object nesting level equal to 9", () => { - const payload = makeNestedObject(9); - expect(sanitizeInput(payload)).toBeDefined(); + it("drops keys that are not plain identifiers", () => { + // $where and friends never reach a query builder here, but a key shaped + // like that has no business in a payload either. + const result = scrubMarkup({ + $where: 'this.role == "admin"', + validKey: "data", + }) as Record; + expect(result.$where).toBeUndefined(); + expect(result.validKey).toBe("data"); }); + }); - it("should allow object nesting level equal to 10", () => { - const payload = makeNestedObject(10); - expect(sanitizeInput(payload)).toBeDefined(); - }); + describe("3. Input sanitization — complexity limits", () => { + const makeNestedObject = (depth: number): unknown => + depth === 0 ? "leaf" : { node: makeNestedObject(depth - 1) }; - it("should reject object nesting level equal to 11", () => { - const payload = makeNestedObject(11); - expect(() => sanitizeInput(payload)).toThrowError( + it("allows nesting up to ten levels and refuses eleven", () => { + expect(scrubMarkup(makeNestedObject(9))).toBeDefined(); + expect(scrubMarkup(makeNestedObject(10))).toBeDefined(); + expect(() => scrubMarkup(makeNestedObject(11))).toThrow( "Input too deeply nested", ); }); - it("should allow objects with exactly 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 50; i++) { - payload[`key_${i}`] = i; - } - expect(sanitizeInput(payload)).toBeDefined(); - }); + it("allows fifty keys and refuses fifty-one", () => { + const fifty: Record = {}; + for (let i = 0; i < 50; i++) fifty[`key_${i}`] = i; + expect(scrubMarkup(fifty)).toBeDefined(); - it("should reject objects with more than 50 keys", () => { - const payload: Record = {}; - for (let i = 0; i < 51; i++) { - payload[`key_${i}`] = i; - } - expect(() => sanitizeInput(payload)).toThrowError("Object too complex"); + const fiftyOne: Record = { ...fifty, key_50: 50 }; + expect(() => scrubMarkup(fiftyOne)).toThrow("Object too complex"); }); - it("should allow arrays with exactly 500 elements", () => { - const payload = new Array(500).fill("valid"); - expect(sanitizeInput(payload)).toBeDefined(); + /** + * The array cap must stay at or above the largest `.max()` any schema + * declares. It sat at 500 while batchUpdateParticipantStatus allowed 2500, + * which made approving a 2000-person roster impossible in one call — and + * the error named neither the real limit nor the field. + */ + it("allows 2500 array elements and refuses 2501", () => { + expect(scrubMarkup(new Array(2500).fill("valid"))).toBeDefined(); + expect(() => scrubMarkup(new Array(2501).fill("invalid"))).toThrow( + "Array too large", + ); }); - it("should reject arrays with more than 500 elements", () => { - const payload = new Array(501).fill("invalid"); - expect(() => sanitizeInput(payload)).toThrowError("Array too large"); + it("refuses a non-finite number", () => { + expect(() => scrubMarkup(Number.POSITIVE_INFINITY)).toThrow( + "Invalid number", + ); + expect(() => scrubMarkup(Number.NaN)).toThrow("Invalid number"); }); }); @@ -259,6 +230,53 @@ describe("Security and Protection Verification Suite", () => { vi.useRealTimers(); }); + + /** + * Backoff is exponential in the violation count, so without a decay that + * can actually fire, one bad afternoon escalates a legitimate attendee to + * five-minute blocks for the rest of the event. + * + * The old decay compared against lastRefill, which is stamped on every + * request — so it could only fire for somebody who had stopped making + * requests altogether, which is precisely who does not need forgiving. + */ + it("forgives violations one clear period at a time", () => { + vi.useFakeTimers(); + const user = "reformed-user"; + + // Trip it twice: the second violation costs 2s. + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // One clear period forgives one step: count 2 -> 1, so the next + // violation is priced at 2^1 rather than 2^2. + vi.advanceTimersByTime(11 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + + // A second clear period clears the slate entirely. + vi.advanceTimersByTime(21 * 60 * 1000); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + + vi.useRealTimers(); + }); + + // A caller who keeps hammering must not have their count decayed by the + // passage of time alone — the decay is measured from the last violation. + it("keeps escalating a caller who never stops violating", () => { + vi.useFakeTimers(); + const user = "persistent-user"; + + rateLimit(user, 1, 0, 1); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(1); + vi.advanceTimersByTime(1100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(2); + vi.advanceTimersByTime(2100); + expect(rateLimit(user, 1, 0, 1).retryAfter).toBe(4); + + vi.useRealTimers(); + }); }); describe("8. DDoS Burst Interception", () => { diff --git a/packages/api/src/middleware/cache.ts b/packages/api/src/middleware/cache.ts index 74f9ffda..24553526 100644 --- a/packages/api/src/middleware/cache.ts +++ b/packages/api/src/middleware/cache.ts @@ -213,11 +213,11 @@ export class CacheService { // Global cache instance export const cache = new CacheService(300, 10000); // 5 minutes default TTL, max 10000 entries -/** - * Cache statistics for metrics/monitoring export - * Access via cache.getStats() or cache.exportStats() +/* + * There is deliberately no exported `cacheStats` snapshot. One existed, taken + * once at module load, so it reported zeroes forever — call cache.getStats() + * for a live reading. */ -export const cacheStats = cache.exportStats(); /** * TTL (seconds) for state an admin can flip mid-event — hackathon status, @@ -232,17 +232,20 @@ export const cacheStats = cache.exportStats(); */ export const VOLATILE_TTL = 5; -// Cache key builders for consistency +/** + * Cache key builders, for the keys that are actually written. + * + * `hackathons()`, `events()`, `user()`, `event()` and `member()` used to be + * here too, and every one described a shape nothing writes — the real keys + * carry a suffix (`events:list:all`, `member:me:`). Builders for keys that + * do not exist are how the invalidation map ended up evicting nothing: they + * read as authoritative and match zero entries. + */ export const CacheKeys = { - user: (userId: string) => `user:${userId}`, userProfile: (userId: string) => `user:${userId}:profile`, admin: (userId: string) => `admin:${userId}`, hackathon: (id: string) => `hackathon:${id}`, - hackathons: () => `hackathons:list`, - event: (id: string) => `event:${id}`, - events: () => `events:list`, judge: (userId: string) => `judge:${userId}`, - member: (userId: string) => `member:${userId}`, projectLeader: (userId: string) => `project-leader:${userId}`, portalContext: (userId: string) => `user:${userId}:portal`, } as const; @@ -276,15 +279,10 @@ export const clearMembershipCaches = (userId: string) => { invalidatePortalContext(userId); }; -// Cache invalidation helpers -export const invalidateUser = (userId: string) => { - cache.deletePattern(`user:${userId}*`); -}; - -export const invalidateHackathons = () => { - cache.deletePattern("hackathon*"); -}; - -export const invalidateEvents = () => { - cache.deletePattern("event*"); -}; +/* + * `invalidateUser`, `invalidateHackathons` and `invalidateEvents` used to live + * here with no callers. Eviction happens through CACHE_INVALIDATION_MAP or by + * id in the resolver that wrote the row — a broad namespace sweep is exactly + * the P5 mistake (`deletePattern("hackathon*")` once wiped every attendee's + * cached registrations on every badge scan). + */ diff --git a/packages/api/src/middleware/http-security.ts b/packages/api/src/middleware/http-security.ts deleted file mode 100644 index 2a4e8a4d..00000000 --- a/packages/api/src/middleware/http-security.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * HTTP Security Headers and Utilities - * Provides comprehensive security headers for API responses - */ - -import { resolveClientIp } from "./security"; - -export interface SecurityHeaders { - "X-Content-Type-Options": string; - "X-Frame-Options": string; - "X-XSS-Protection": string; - "Strict-Transport-Security": string; - "Content-Security-Policy": string; - "Referrer-Policy": string; - "Permissions-Policy": string; - "X-Request-Id": string; -} - -export interface CacheHeaders { - "Cache-Control": string; - Vary: string; -} - -export interface RateLimitHeaders { - "X-RateLimit-Limit": string; - "X-RateLimit-Remaining": string; - "X-RateLimit-Reset": string; - "Retry-After"?: string; -} - -/** - * Generate strict security headers for API responses - */ -export async function getSecurityHeaders(): Promise { - const crypto = await import("crypto"); - const generateRequestId = () => crypto.randomUUID(); - - return { - // Prevent MIME type sniffing - "X-Content-Type-Options": "nosniff", - - // Prevent clickjacking - "X-Frame-Options": "DENY", - - // Enable XSS protection (legacy but still useful) - "X-XSS-Protection": "1; mode=block", - - // Force HTTPS for 1 year - "Strict-Transport-Security": "max-age=31536000; includeSubDomains", - - // Content Security Policy - strict for API - // Relaxed to allow API responses (script-src for CSP nonce support) - "Content-Security-Policy": - "default-src 'none'; script-src 'self'; frame-ancestors 'none'", - - // Referrer policy - "Referrer-Policy": "strict-origin-when-cross-origin", - - // Permissions policy - disable all features - "Permissions-Policy": - "geolocation=(), microphone=(), camera=(), usb=(), midi=()", - - // Request ID for observability - "X-Request-Id": generateRequestId(), - }; -} - -/** - * Generate cache control headers based on cacheability - */ -export function getCacheHeaders(options: { - cacheable: boolean; - maxAge?: number; - private?: boolean; - mustRevalidate?: boolean; -}): CacheHeaders { - const { - cacheable, - maxAge = 300, - private: isPrivate = true, - mustRevalidate = true, - } = options; - - if (!cacheable) { - return { - "Cache-Control": "no-store, no-cache, must-revalidate, private", - Vary: "Accept-Encoding, Authorization", - }; - } - - const directives = [isPrivate ? "private" : "public", `max-age=${maxAge}`]; - - if (mustRevalidate) { - directives.push("must-revalidate"); - } - - return { - "Cache-Control": directives.join(", "), - Vary: "Accept-Encoding, Authorization", - }; -} - -/** - * Generate rate limit headers - */ -export function getRateLimitHeaders( - limit: number, - remaining: number, - resetTimestamp: number, - retryAfter?: number, -): RateLimitHeaders { - const headers: RateLimitHeaders = { - "X-RateLimit-Limit": limit.toString(), - "X-RateLimit-Remaining": Math.max(0, remaining).toString(), - "X-RateLimit-Reset": resetTimestamp.toString(), - }; - - if (retryAfter !== undefined) { - headers["Retry-After"] = retryAfter.toString(); - } - - return headers; -} - -/** - * Get client IP address from request, considering proxies - */ -export function getClientIp(request: Request): string { - // Check X-Forwarded-For header (from proxies/load balancers) - const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) { - // From the right, not the left: the leading entries are caller-supplied. - return resolveClientIp(forwardedFor); - } - - // Check X-Real-IP header - const realIp = request.headers.get("x-real-ip"); - if (realIp) { - return realIp.trim(); - } - - // Fallback to unknown - return "unknown"; -} - -/** - * Generate a fingerprint for rate limiting - * Combines IP, user agent, and other factors - */ -export function getRequestFingerprint( - request: Request, - userId?: string, -): string { - const ip = getClientIp(request); - const userAgent = request.headers.get("user-agent") || "unknown"; - - // Hash the user agent to keep fingerprint shorter - const uaHash = simpleHash(userAgent); - - if (userId) { - return `user:${userId}:${ip}`; - } - - return `anon:${ip}:${uaHash}`; -} - -/** - * Simple hash function for strings - */ -function simpleHash(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32-bit integer - } - return Math.abs(hash).toString(36); -} - -/** - * Apply all headers to a Response object - */ -export async function applySecurityHeaders( - response: Response, - options?: { - cacheable?: boolean; - maxAge?: number; - rateLimit?: { - limit: number; - remaining: number; - reset: number; - retryAfter?: number; - }; - request?: Request; // Optional request for X-Request-Id - }, -): Promise { - const headers = new Headers(response.headers); - - // Apply security headers - const securityHeaders = await getSecurityHeaders(); - Object.entries(securityHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply cache headers - const cacheHeaders = getCacheHeaders({ - cacheable: options?.cacheable ?? false, - maxAge: options?.maxAge, - }); - Object.entries(cacheHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - - // Apply rate limit headers if provided - if (options?.rateLimit) { - const rateLimitHeaders = getRateLimitHeaders( - options.rateLimit.limit, - options.rateLimit.remaining, - options.rateLimit.reset, - options.rateLimit.retryAfter, - ); - Object.entries(rateLimitHeaders).forEach(([key, value]) => { - if (value !== undefined) { - headers.set(key, value); - } - }); - } - - // Clone and read the body to avoid "body already consumed" errors - const body = await response.clone().arrayBuffer(); - - return new Response(body, { - status: response.status, - statusText: response.statusText, - headers, - }); -} diff --git a/packages/api/src/middleware/security.ts b/packages/api/src/middleware/security.ts index fd443f43..1acb5d03 100644 --- a/packages/api/src/middleware/security.ts +++ b/packages/api/src/middleware/security.ts @@ -1,13 +1,24 @@ -import { TRPCError } from "@trpc/server"; -import sanitizeHtml from "sanitize-html"; interface RateLimitRecord { tokens: number; lastRefill: number; violations: number; blockedUntil: number; + /** + * When this caller last exceeded their bucket. + * + * Separate from lastRefill because that is stamped on every request, so a + * decay measured against it can only fire for somebody who has stopped + * making requests entirely. Backoff is exponential in `violations`, so + * without a decay that actually fires, one bad afternoon escalates a + * legitimate user to five-minute blocks for the rest of the event. + */ + lastViolation: number; } +/** How long a caller must behave for one violation to be forgiven. */ +const VIOLATION_DECAY_MS = 10 * 60 * 1000; + const MAX_RATE_LIMIT_STORE_SIZE = 10000; // Limit rate limit store size to prevent memory bloat const MAX_IP_TRACKING_STORE_SIZE = 50000; // Limit IP tracking store size @@ -39,12 +50,33 @@ const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS ?? 1); * pin a bucket to a victim's address to have that victim blocked. Only the * entries our own proxies appended can be trusted, and those are at the end. */ +/** + * Logged once per process, so the hop count can be checked against reality + * instead of assumed. + * + * Getting TRUSTED_PROXY_HOPS wrong is silent in both directions and expensive + * both ways: too few and a CDN address becomes everyone's bucket, so one + * limit covers the entire internet; too many and the value is caller-supplied, + * letting somebody pick their own bucket or pin a block onto a victim. One + * line at startup is enough to confirm which shape the deployment actually + * has, and costs nothing per request. + */ +let loggedForwardedForShape = false; + export const resolveClientIp = (forwardedFor: string | null | undefined) => { const parts = (forwardedFor ?? "") .split(",") .map((part) => part.trim()) .filter(Boolean); + if (!loggedForwardedForShape && parts.length > 0) { + loggedForwardedForShape = true; + // eslint-disable-next-line no-console + console.log( + `[Security] x-forwarded-for has ${parts.length} entr${parts.length === 1 ? "y" : "ies"}; TRUSTED_PROXY_HOPS=${TRUSTED_PROXY_HOPS} selects index ${Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS)}. Expect hops = entries - 1.`, + ); + } + if (parts.length === 0) return "unknown"; const index = Math.max(0, parts.length - 1 - TRUSTED_PROXY_HOPS); @@ -83,9 +115,17 @@ const evictOldest = ( const enforceSizeLimit = () => { const now = Date.now(); - // Expired records first, so eviction usually has nothing left to do. + // Idle records first, so eviction usually has nothing left to do. + // + // Both halves of this condition matter. A healthy record carries + // `blockedUntil: 0`, so testing that alone deleted EVERY bucket on every + // tick — the whole token-bucket store was erased once a minute and each + // caller got a fresh full bucket back regardless of how they had behaved, + // which quietly made the limiter little more than a per-minute burst cap. for (const [key, value] of rateLimitStore.entries()) { - if (now > value.blockedUntil) { + const idle = now - value.lastRefill > 30 * 60 * 1000; + const blockElapsed = now > value.blockedUntil; + if (idle && blockElapsed) { rateLimitStore.delete(key); } } @@ -154,6 +194,7 @@ export function rateLimit( lastRefill: now, violations: 0, blockedUntil: 0, + lastViolation: 0, }; rateLimitStore.set(identifier, record); } @@ -170,8 +211,23 @@ export function rateLimit( record.tokens = Math.min(maxTokens, record.tokens + refill); record.lastRefill = now; + // Decay one step per clear period since the last violation, so somebody who + // tripped the limit once and then behaved normally returns to a clean slate + // instead of carrying an escalating backoff for the rest of the weekend. + // Applied before the check below so a caller who has waited out their + // penalty is not immediately re-escalated from the old count. + if (record.violations > 0 && record.lastViolation > 0) { + const clearPeriods = Math.floor( + (now - record.lastViolation) / VIOLATION_DECAY_MS, + ); + if (clearPeriods > 0) { + record.violations = Math.max(0, record.violations - clearPeriods); + } + } + if (record.tokens < tokensToConsume) { record.violations++; + record.lastViolation = now; const backoffSeconds = Math.min(Math.pow(2, record.violations - 1), 300); record.blockedUntil = now + backoffSeconds * 1000; @@ -183,10 +239,6 @@ export function rateLimit( record.tokens -= tokensToConsume; - if (record.violations > 0 && elapsed > 600) { - record.violations = Math.max(0, record.violations - 1); - } - return { allowed: true }; } @@ -217,135 +269,22 @@ export const RATE_LIMITS = { }, } as const; -const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { - allowedTags: [], - allowedAttributes: {}, - disallowedTagsMode: "discard", - nonTextTags: ["style", "script", "textarea", "noscript", "option", "xmp"], -}; - -export function sanitizeInput(input: unknown, depth: number = 0): unknown { - if (depth > 10) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Input too deeply nested", - }); - } - - if (input === null || input === undefined) { - return input; - } - - if (typeof input === "string") { - const sanitized = sanitizeHtml(input, SANITIZE_OPTIONS) - .trim() - .slice(0, 10000); - - if (hasInjectionPattern(sanitized)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input", - }); - } - - return sanitized; - } - - if (typeof input === "number") { - if (!Number.isFinite(input)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid number", - }); - } - return input; - } - - if (typeof input === "boolean") { - return input; - } - - if (Array.isArray(input)) { - if (input.length > 500) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Array too large", - }); - } - return input.map((item) => sanitizeInput(item, depth + 1)); - } - - if (typeof input === "object") { - const keys = Object.keys(input as object); - if (keys.length > 50) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Object too complex", - }); - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(input as object)) { - if (key === "__proto__" || key === "constructor" || key === "prototype") { - continue; - } - if (!/^[\w.-]{1,100}$/.test(key)) { - continue; - } - sanitized[key] = sanitizeInput(value, depth + 1); - } - return sanitized; - } - - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Invalid input type", - }); -} - -function hasInjectionPattern(str: string): boolean { - const patterns = [ - // SQL SELECT/INSERT/UPDATE/DELETE keywords with FROM/INTO/TABLE/DATABASE - /(\b(union|select|insert|update|delete|drop|create|alter|exec|execute)\b.*\b(from|into|table|database)\b)/i, - // SQL comment sequences: "--" must be followed by space or end-of-string (not in URLs like some--repo) - /(--\s|--$)/, - // Block comment: /* only when followed by * content - /\/\*[\s\S]*?\*\//, - // NoSQL injection - /\$where/i, - /\$gt|\$lt|\$ne|\$eq/i, - // XSS - /
+

+ {currentReg.registrationStatus === "approved" + ? "You're accepted — check in at the event before submitting. Find a volunteer and have your badge scanned." + : currentReg.registrationStatus === "pending" + ? "Your registration is still being reviewed. You can form a team once you have been accepted." + : `Your registration for this hackathon is ${currentReg.registrationStatus}.`} +

+
+ )} + {/* The window this form is gated on, said before it is filled in. Without it an attendee wrote a full description and learned it was refused only on submit. */} diff --git a/sites/mainweb/app/api/csp-report/route.ts b/sites/mainweb/app/api/csp-report/route.ts new file mode 100644 index 00000000..51b1c548 --- /dev/null +++ b/sites/mainweb/app/api/csp-report/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +/** + * Where Content-Security-Policy violations land while the policy is + * report-only. + * + * The point of report-only is to find out what the policy *would* have blocked + * before it blocks it — and a policy nobody can see the reports from tells you + * nothing. Browsers POST here on every violation; the container collects + * stderr, so `[CSP]` lines in the logs are the record of what to fix (or + * allow) before CSP_ENFORCE is turned on. + * + * Deliberately unauthenticated: the browser sends these without credentials, + * and the body is a report about our own pages. It is rate-limited by being + * useless to an attacker — the worst case is noise in a log, so the size cap + * below is the only guard that matters. + */ + +/** Reports are small. Anything larger is not a browser. */ +const MAX_REPORT_BYTES = 8 * 1024; + +export async function POST(request: NextRequest) { + try { + const body = await request.text(); + + if (body.length > MAX_REPORT_BYTES) { + return new NextResponse(null, { status: 413 }); + } + + // Two formats in the wild: the legacy `report-uri` shape + // ({"csp-report": {...}}) and the newer Reporting API array. Log whichever + // arrives rather than parsing both into one shape — this is a diagnostic, + // not a data pipeline. + console.warn("[CSP] violation report:", body.slice(0, MAX_REPORT_BYTES)); + } catch (error) { + console.error("[CSP] failed to read a violation report:", error); + } + + // 204 regardless: a failed report must never look like a page error to the + // browser that sent it. + return new NextResponse(null, { status: 204 }); +} diff --git a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx index 1f98078a..e3a7a89b 100644 --- a/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AnnouncementsTab.tsx @@ -26,8 +26,28 @@ const AUDIENCES = [ label: "On site", hint: "Checked in at the door", }, + { + id: "not_accepted" as const, + label: "Not accepted", + hint: "Rejected and waitlisted — excluded from every other audience", + }, ]; +/** + * A starting point for the one message that is hard to write, and easy to get + * wrong by sending the wrong tone from a blank box. Entirely editable — it is + * prefilled only when the audience is selected and nothing has been typed yet. + */ +const NOT_ACCEPTED_TEMPLATE = { + subject: "An update on your application", + heading: "An update on your application", + body: [ + "Thank you for applying. We had far more applications than places this year, and we were not able to offer you one.", + "This is not a judgement of you or your work — the numbers simply did not allow it, and turning people down is the part of running this we like least.", + "We would genuinely welcome an application from you next time, and our other events are open to everyone in the meantime.", + ].join("\n\n"), +}; + type Audience = (typeof AUDIENCES)[number]["id"]; export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) { @@ -211,7 +231,21 @@ export function AnnouncementsTab({ hackathonId }: { hackathonId: string }) {