diff --git a/AGENTS.md b/AGENTS.md index ae1f930..283fcbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Guidelines for AI coding agents working on the official Micro Club website. - Rate limits are counted in MongoDB, shared by every server instance: the Server Actions call `isRateLimited()` from `src/lib/rate-limit.ts` (`rate_limits` collection, keyed by `clientIp()`), Better Auth uses `rateLimit.storage: "database"` (`rateLimit` collection, on in production only). Both read the client IP from `x-real-ip`, then `x-forwarded-for`. - `pnpm db:seed-registrations` fills the local database with fake registrations (emails `@seed.test`, replaced on each run; refuses a non-local `MONGODB_URI`). Their student IDs do not exist, so their page shows "File unavailable". - Emails go through `sendEmail()` in `src/lib/email.ts` (Resend, `RESEND_API_KEY`) with the shared branded layout (`renderEmail`). Without a key, development prints them to the server console. -- Student IDs are uploaded by the browser straight to Cloudinary with a signature from `src/actions/public/sign-student-id-upload.ts` (`authenticated` assets, no public URL), then `src/actions/public/submit-registration.ts` reads the upload back and saves the registration. Needs the `CLOUDINARY_*` variables. +- Student IDs are uploaded by the browser straight to Cloudinary with a signature from `src/actions/public/sign-student-id-upload.ts` (`authenticated` assets, no public URL), then `src/actions/public/submit-registration.ts` checks Cloudinary's signature of the upload response (no Admin API call: the free plan allows 500 per hour) and saves the registration. Rate limits there are per email, with a loose per-IP backstop (campus networks and CGNAT share one IP). Needs the `CLOUDINARY_*` variables. - Registrations open and close with `REGISTRATIONS_WINDOW` in `src/config/registrations.ts` (`opensAt` / `closesAt`, ISO dates or `null`), read through `registrationsPhase()` in `src/lib/registrations/window.ts`. Outside the window `/registrations` is a 404 and the actions refuse submissions; the menu bar shows "Opening soon" or "Register now" with a countdown (`RegistrationsButton` in `RegistrationsCorner`), or nothing when closed. ## Admin area and auth diff --git a/src/actions/public/sign-student-id-upload.ts b/src/actions/public/sign-student-id-upload.ts index 38cce53..bfb4ad7 100644 --- a/src/actions/public/sign-student-id-upload.ts +++ b/src/actions/public/sign-student-id-upload.ts @@ -11,11 +11,12 @@ export type SignatureResult = | { status: "rate-limited" } | { status: "error" } -const RATE_LIMIT = { limit: 10, windowMs: 60 * 60 * 1000 } +/** Per IP, loose: many students share one address (campus network, CGNAT), and retries re-sign. */ +const RATE_LIMIT = { limit: 60, windowMs: 60 * 60 * 1000 } /** * Hands the browser a signed, short-lived permission to upload one student ID to Cloudinary. - * Each IP gets 10 per hour, so the signature cannot be used to fill the storage. + * Each IP gets 60 per hour, so the signature cannot be used to fill the storage. */ export async function createStudentIdUploadSignature(): Promise { if (!areRegistrationsOpen()) return { status: "error" } diff --git a/src/actions/public/submit-registration.ts b/src/actions/public/submit-registration.ts index 9a480f3..630c358 100644 --- a/src/actions/public/submit-registration.ts +++ b/src/actions/public/submit-registration.ts @@ -3,7 +3,7 @@ import { headers } from "next/headers" import { PHONE_COUNTRIES, STUDENT_ID_RULES } from "@/const/registrations" -import { deleteStudentId, getStudentIdUpload, type UploadedStudentId } from "@/lib/cloudinary" +import { deleteStudentId, verifyStudentIdUpload, type UploadedStudentId } from "@/lib/cloudinary" import { connectToDatabase } from "@/lib/mongodb" import { clientIp, isRateLimited } from "@/lib/rate-limit" import { RegistrationModel } from "@/lib/registrations/model" @@ -35,7 +35,12 @@ export type RegistrationResult = | { status: "rate-limited" } | { status: "error" } -const RATE_LIMIT = { limit: 5, windowMs: 60 * 60 * 1000 } +/** + * Per IP, a loose backstop only: a campus network or a mobile carrier (CGNAT) puts many students + * behind one address. The real limit is per email, the one key a registration has. + */ +const IP_RATE_LIMIT = { limit: 30, windowMs: 60 * 60 * 1000 } +const EMAIL_RATE_LIMIT = { limit: 5, windowMs: 60 * 60 * 1000 } const MONGO_DUPLICATE_KEY = 11000 function readUploadReference(raw: FormDataEntryValue | null): StudentIdUpload | null { @@ -44,7 +49,11 @@ function readUploadReference(raw: FormDataEntryValue | null): StudentIdUpload | const valid = typeof upload.publicId === "string" && STUDENT_ID_PUBLIC_ID.test(upload.publicId) && - (upload.resourceType === "image" || upload.resourceType === "raw") + (upload.resourceType === "image" || upload.resourceType === "raw") && + typeof upload.version === "number" && + typeof upload.signature === "string" && + typeof upload.format === "string" && + typeof upload.bytes === "number" return valid ? (upload as StudentIdUpload) : null } catch { return null @@ -63,8 +72,8 @@ function uploadProblem(stored: UploadedStudentId) { * Saves a registration: the form values (`values`, JSON), a reference to the student ID the browser * already uploaded to Cloudinary (`upload`, JSON) and the honeypot, as one `FormData`. * - * Order: validate -> rate limit -> duplicate email -> read the upload back from Cloudinary -> - * save in MongoDB. A rejected registration (invalid file, duplicate) deletes its upload; after a + * Order: validate -> rate limit (per IP and per email) -> duplicate email -> check Cloudinary's + * signature of the upload -> save in MongoDB. A rejected registration (invalid file, duplicate) deletes its upload; after a * failure that may pass (database down) the upload stays, so the retry reuses it. A filled * honeypot gets a fake success (bots learn nothing). Needs `MONGODB_URI` and the `CLOUDINARY_*` * variables (`.env.example`). @@ -108,8 +117,12 @@ export async function submitRegistration(formData: FormData): Promise deleteStudentId(upload).catch((error) => @@ -119,13 +132,13 @@ export async function submitRegistration(formData: FormData): Promise boolean + } +).verify_api_response_signature + /** - * Reads an uploaded student ID back from Cloudinary, so the size and format that get stored are - * the real ones, not what the browser claims. `null` when there is no such upload. + * Checks an upload the browser reports against Cloudinary's signature of its upload response + * (`public_id` and `version`, signed with the API secret), so no Admin API call is needed: the + * free plan allows 500 of those per hour, which would cap the registrations. The format was already + * enforced by Cloudinary (`allowed_formats` is part of the signed upload), the size by the plan's + * file limit; the stored URL is signed here. `null` when the signature does not match. */ -export async function getStudentIdUpload( - publicId: string, - resourceType: string, -): Promise { +export function verifyStudentIdUpload(upload: { + publicId: string + resourceType: string + version: number + signature: string + format: string + bytes: number +}): UploadedStudentId | null { configure() - try { - const resource = await cloudinary.api.resource(publicId, { + if (!verifyApiResponseSignature(upload.publicId, upload.version, upload.signature)) return null + + return { + url: cloudinary.url(upload.publicId, { type: "authenticated", - resource_type: resourceType, - }) - return { - url: resource.secure_url, - publicId: resource.public_id, - resourceType: resource.resource_type, - format: resource.format, - bytes: resource.bytes, - } - } catch (error) { - if ((error as { error?: { http_code?: number } }).error?.http_code === 404) return null - throw error + resource_type: upload.resourceType, + version: upload.version, + format: upload.format, + sign_url: true, + secure: true, + }), + publicId: upload.publicId, + resourceType: upload.resourceType, + format: upload.format, + bytes: upload.bytes, } } diff --git a/src/lib/registrations/upload-student-id.ts b/src/lib/registrations/upload-student-id.ts index 68ec3be..b4e4e97 100644 --- a/src/lib/registrations/upload-student-id.ts +++ b/src/lib/registrations/upload-student-id.ts @@ -1,9 +1,16 @@ import type { StudentIdUploadSignature } from "@/lib/cloudinary" -/** Reference to a student ID already stored in Cloudinary, sent with the registration. */ +/** + * Reference to a student ID already stored in Cloudinary, sent with the registration: Cloudinary's + * upload response, whose `signature` (over `publicId` and `version`) the server checks. + */ export type StudentIdUpload = { publicId: string resourceType: string + version: number + signature: string + format: string + bytes: number } /** Public ids the server accepts: a file in the student ID folder, no path tricks. */ @@ -34,5 +41,12 @@ export async function uploadStudentId( if (!response.ok || !result?.public_id) throw new Error(result?.error?.message ?? `Upload failed (${response.status})`) - return { publicId: result.public_id, resourceType: result.resource_type } + return { + publicId: result.public_id, + resourceType: result.resource_type, + version: result.version, + signature: result.signature, + format: result.format, + bytes: result.bytes, + } }