The unauthenticated surface
What a caller with no session can reach, measured rather than assumed.
Audited 14 August 2026 against main (backend v0.8.4). Findings acted on 16 August 2026 — see What changed at the end; the counts below are post-change.
Method
sis_backendgo/internal/router/unauth_test.go builds the real production handler — the whole middleware chain, not a stub — and fires every registered route at it with no session cookie, then again with a well-formed session cookie that is not in the store.
It is hermetic: sql.Open("postgres", "") never dials, and SessionService is backed by a nil Valkey client. That is a safety property, not a shortcut. A route that really is open and really does touch the database answers 500 instead of doing anything, which is the only reason it is safe to fire POST and DELETE at all 1,168 patterns — including POST /proxy/invitations/accept.
cd sis_backendgo
go test ./internal/router/ -run 'Anonymous|UnknownSession|PublicSurface|Preflight|TrailingSlash|DevLogin|RateLimit' -vThe nil session store doubles as an assertion that an unreachable Valkey fails closed — an outage that quietly became an authentication bypass would be far worse than the outage.
Result
| Registration kind | Behind protect? | Count | Anonymous caller gets |
|---|---|---|---|
Secure / Node().Handle | yes | 1,083 | 401 |
SelfService | yes | 71 | 401 |
Ungated | yes | 0 | — |
Public | no | 7 | reaches the handler |
Custom | no | 7 | reaches the handler |
| Total | 1,168 |
All 1,154 authenticated routes returned 401, with no cookie and with an unknown session cookie alike. No gated route leaked. The 14 reachable routes are exactly the ones declared reachable — reviewed one by one below.
(The original audit measured 1,236 routes, 17 reachable. Removing the admissions domain took 68 routes with it, three of them public.)
The counts above are what the audit measured. Two routes have been added to the public surface since: GET /api/v1/core/ui-text/live and GET /api/v1/core/demo-accounts, both reviewed in the table below and pinned like the rest by TestPublicSurfaceIsPinned. Anything added here has to move that test, which is the point of it — the pin costs a reviewer's attention on purpose.
OPTIONS is the one method that never reaches a permission check: CORS is applied outside the mux, so a preflight is answered 204 with an empty body before auth runs. Correct, and now pinned by a test so it stays that way.
Why this test exists alongside coverage_test.go
coverage_test.go checks what each route declares. This checks what it does. Before authz.Kind, every exempt route carried a Reason and nothing distinguished "authenticated but deliberately not node-gated" from "open to the world" — so moving a route from SelfService to Public passed the entire suite unchanged. Kind records which constructor registered each route, and TestPublicSurfaceIsPinned fixes the resulting set as a literal.
When adding a route, prefer SelfService over Public. An applicant or proxy holding a magic-link session is authenticated, just not role-bearing.
The 16 reachable routes
Sound
| Route | Notes |
|---|---|
GET /health, GET /debug/routes, GET /api/v1/core/health | Static strings. /health includes the version; not worth withholding. |
GET /api/v1/core/settings/branding | Filtered by publicBrandingKeys, distinct from the signed-in fullBrandingKeys. Correct split. |
GET /api/v1/core/ui-text/live | The institution's published wording for interface strings, so the login page renders it before a session exists. Returns only strings an administrator here deliberately wrote for that page — strictly less than the branding route above. No catalog, no personal data, no key that is not already in the shipped frontend bundle. Writes are gated on ADMIN.UI_TEXT. |
GET /api/v1/core/demo-accounts | The one route on this list whose safety is a property of the deployment, not of the handler. It publishes working logins — role, email, shared password — for the sanitized demo, so a visitor with no account can try /demo. It returns an empty list unless the backend runs with DEMO_MODE=true, which docker-compose.prod.yml sets on demo-backend alone, and that instance's database is sanitized data reloaded weekly from a fixed artifact. Setting DEMO_MODE on an instance holding real people would publish real logins; nothing in the code can prevent that, so it is called out here and in the compose file. TestDemoAccountsEmptyWithoutDemoMode guards the default. |
GET /api/v1/auth/sso/providers | Display name and slug only. On the sanitized demo it answers an empty list and the three routes that admit somebody — saml/{slug}/login, .../acs, .../metadata — are not registered at all, because that flow ends in JIT provisioning and would create a real person as a user on a database of fabricated people. Pinned by TestSAMLAbsentOnDemo. |
POST /api/v1/auth/token-login, POST /api/v1/auth/set-password | 256-bit crypto/rand tokens, SHA-256 hashed at rest, single-use (used_at), expiry and revocation checked. Both deliberately return one generic message across every failure mode, with a comment explaining why. |
POST /api/v1/proxy/invitations/accept | The token is the credential, and it holds up: 256-bit crypto/rand, expiry checked, status must be pending so acceptance is single-use, both the grant and the acceptance are audit-logged with IP and user agent. |
GET /api/v1/auth/saml/{slug}/login, POST .../acs | Redirect targets pass through sanitizeRedirect, so no open redirect. Assertion validation is the authentication. |
GET .../saml/{slug}/metadata | SP metadata must be IdP-fetchable by design. Absent on the demo, with the rest of the SAML flow. |
Findings
1. POST /auth/login discloses which accounts exist — low — FIXED
authService.Login returned "Invalid email or password" for an unknown address but "Account is inactive" for a known, disabled one. The message was the oracle, and it leaked not just existence but dormancy — the accounts nobody watches and whose passwords are oldest. Separately, CheckPassword only ran once a user had been found, so its cost was a second, message-independent oracle for the same fact. That cost is larger than first reported: passwords are PBKDF2-SHA256 at 1.2 million iterations, not bcrypt.
Both closed. All three failure modes now return errInvalidCredentials, and an unknown address is verified against a package-level decoyUser so the hash runs either way. TestLoginFailuresAreIndistinguishable asserts the messages are identical; TestDecoyMatchesRealHashCost asserts the decoy uses the same algorithm and iteration count, which pins the timing property without a flaky wall-clock assertion.
DevLogin deliberately keeps the specific messages: it is dev-only and hands out a session with no password at all, so there is no anonymous caller to protect.
2. GET /auth/verify-token/{token} distinguishes unknown from expired — low — FIXED
Returned "Invalid or expired token" when no row matched but "Token has expired or already been used" when one did, confirming a token had once been real. TokenLogin and SetPasswordWithToken already collapsed every case and said why in comments; VerifyToken was missed.
All three now return errInvalidToken — "This link is no longer valid. Please request a new one." Generic about the token, informative about what to do next.
3. The rate-limit bucket is chosen by the caller — medium — OPEN, reviewed and accepted
middleware.clientIP (sis_backendgo/internal/middleware/ratelimit.go) reads the X-Real-IP request header directly, with no trust boundary — it is honoured whether or not the request came through a proxy. So the 10/minute limiter on login, request-access-link, token-login, set-password and the SAML handshake buckets by a value the attacker picks, and varying it gives unlimited attempts. Measured: 40/40 spoofed requests evaded the limiter, while 40 from a fixed value were throttled.
Correction to the original write-up. It said nginx "covers the public path." That is not what happens. sis_frontend/nginx.conf sets proxy_set_header X-Real-IP $remote_addr inside the /api/ location, where $remote_addr is nginx's immediate peer — the Caddy container. So every request through the deployed stack arrives with the same X-Real-IP, and clientIP prefers that header unconditionally. nginx does prevent spoofing, but by collapsing the entire internet into one bucket: 10 requests per minute shared across all callers for login, request-access-link, verify-token, token-login, set-password and both SAML routes.
So there are two distinct problems, not one:
- Availability, through the proxy. One global bucket. How visible this is depends on how much traffic uses password login rather than SSO; grep production logs for
rate_limit_exceededbefore judging severity. - Bypass, direct to
:8000. No proxy rewrites the header there, so the caller picks the key and the limiter is decorative. Measured: 40/40 spoofed requests evaded it.
Development has neither symptom — no proxy, no header, so it falls through to RemoteAddr and behaves correctly. That is why this is invisible everywhere you would naturally look.
Reviewed 16 August 2026 and accepted as-is for now. The fix when it is taken up: honour forwarded headers only when RemoteAddr is inside a configured trusted-proxy CIDR, and take the rightmost untrusted entry of X-Forwarded-For rather than the leftmost — $proxy_add_x_forwarded_for appends, so the leftmost entry is whatever the caller sent. middleware.RealIP takes the leftmost today and has the same flaw independently of clientIP. TestRateLimitBucketIsClientControlled pins the current behaviour and will fail when this is fixed — delete it then and update this page.
Two smaller things in the same file, for whoever touches it: allow() is a fixed window despite its token-bucket comment, so a caller gets 2× the limit across a window boundary; and the limiter is per-process in memory, so sis and sis2 count separately and a restart clears it.
4. POST /api/v1/public/start-application writes to a table that does not exist — RESOLVED by removal
admissionsService.StartApplication inserts into admission_applications with person_id, application_type_id, applied_term_id, applied_program_id, status_id and is_international. The live admissions.admission_applications has none of those columns — it has applicant_id NOT NULL, application_status_id, intended_term_id, intended_major_id. Every call is a 500. The public applicant-intake endpoint has never worked.
This is the third domain found modelled against a schema that was never committed, after term fees and formbuilder.
The security half matters when the schema is aligned. As written the handler fabricates person_id = "pending-<caller-supplied-email>-<nanos>" from unvalidated input, and reg.Public applies no rate limiter — unlike the pre-session auth routes, which at least get authActor. An anonymous, unthrottled, un-deduplicated insert into a real table is a spam and storage-exhaustion endpoint.
Resolved by deleting the route, along with the rest of the admissions domain — a different system is replacing it. If intake is rebuilt, it needs the authActor wrapper (or its own limiter) in the same change that fixes the schema; reg.Public applies neither.
5. GET /api/v1/public/terms and /programs return [] unconditionally — RESOLVED by removal
Both were stubs (// For now, return an empty list). No exposure; they simply did nothing. Removed with the admissions domain, taking all three /api/v1/public/* routes off the unauthenticated surface.
6. RequestAccessLink mints a token and sends no email — deliberate, documented
// TODO: Send email with magic link. The row was written, the mail was not.
Investigating the fix showed RequestAccessLink is the only code path in the backend that creates an access token (PASSWORD_RESET and EMAIL_VERIFY are declared but never issued), and the flow landed at /my-application — it existed solely to log applicants into the portal. Rather than wire an email into a flow whose replacement will define its own login, the screen that falsely claimed "a magic link has been sent" was deleted with the rest of the applicant portal, and the endpoints were left registered and working.
So four of the fourteen reachable routes currently have no consumer: request-access-link, verify-token/{token}, token-login and set-password. That is a decision, not an oversight — recorded here so the next audit does not rediscover it as a finding. Whoever builds the replacement admissions system either adopts them or deletes them.
What changed
16 August 2026, acting on the findings above.
| Finding | Outcome |
|---|---|
| 1 — login enumeration (message + timing) | Fixed; two regression tests |
2 — VerifyToken token oracle | Fixed; one message across all three token endpoints |
| 3 — caller-chosen rate-limit bucket | Reviewed, accepted, left open. Write-up corrected — nginx does not "cover" it |
| 4 — anonymous write to a nonexistent table | Route removed with the admissions domain |
5 — /public/terms, /public/programs stubs | Removed with the admissions domain |
| 6 — magic link sends no email | UI removed; endpoints kept, deliberately consumerless |
The admissions removal (migration 166_retire_admissions_nodes.sql) took the Go domain, its 68 routes, the frontend views and the applicant portal. No schema and no rows were touched — a different system is replacing admissions and may keep them.
The permission nodes needed care, and the shape of that care is worth remembering: three other domains gate routes on ADMISSIONS.*, so a prefix delete would have turned working endpoints into silent 403s. ADMISSIONS.APPLICATIONS in particular still gates the five /roles/applicants routes — a person becoming an applicant is core business logic that outlives the admissions UI — so the node stayed and only its menu presence went.
Two things surfaced while verifying that, both worth knowing independently of this change:
is_menu_itemis not read by the menu builder.filterMenuTreeselects onnode_type(MENUandSCREENrender;ACTION/DATA/TAB/FIELDare skipped) and never consults the flag. Clearingis_menu_itemalone leaves the entry on screen;node_type = 'ACTION'is what actually removes it.- Clearing
route_pathdoes not unlink a menu group.defaultMenuRoutesinauthorization/service.gohard-codes a fallback per top-level node code, soADMISSIONSkept pointing at the deleted/admissions/applicationsuntil that map was edited too.
Deploy note: the menu comes from the 15-minute cached permission blob (user_permissions:{uid}:{roleID}), so signed-in users keep seeing the old sidebar until their cache expires or they switch roles. Not a bug, but it will look like one.
Scope
This audits authentication only: whether a caller with no session gets in. Whether a signed-in student can reach an admin route is authorization — enforced by authz.Registrar and the permission guard, covered by coverage_test.go and nodes_test.go, and it would take a separate role-by-role probe with real sessions to measure the same way.
Also out of scope: anything in front of the backend. Caddy terminates TLS and routes /sdasis/* to the frontend nginx, which proxies /sdasis/api/* to :8000. The Mailpit UI sits behind basic auth there, and it holds magic links and password resets — a separate review.