Anonymous visitors get a 'log in to continue' interstitial for every write action (OIDC anonymous-read mode); one anonymous write leak in ssh-keys #502
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#502
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
With #345 public browsing (anonymous read) enabled in OIDC mode, a signed-out visitor can browse everything but must be able to execute ZERO writes — fork, star, watch, new repo/org/issue/comment/PR, reactions, and "any other way to write the server". Every attempted write must refuse server-side, and the UI must route the visitor to a new "log in to continue" interstitial that explains the action needs an account and offers OIDC login returning them to the attempted action.
Current state (verified against the tree; static reading, no repro)
Server-side: per-route gates exist and mostly refuse anonymous — with two defects
The identity contract (internal/identity/access.go
Resolve, 295-347) gives an anonymous principal exactly one grant:readon public visibility (line 343).CheckRead/CheckRole/CheckOrgOwner(internal/identity/gate.go:40/64/79) return 401 for anonymous below the required level. Per-surface write gates verified:repoPut->gate(AuthWrite)repoDeleteAuthAdminPOST ops/{op}canEditProfile-> gate(AuthWrite)requireAuthenticatedrequireAuthenticated+requireRole("write")requireAuthenticatedrequireAuthrequireAuth+requireRole(admin)CheckOrgOwner/CheckRolep.Writeelse anonymous 401checkCreateanonymous 401requireRolecopiesAuth: AuthRead; internal/api/sshkeys.go:71-95gate(AuthRead)thenSSHKeys.Add(ctx, p.Name, ...)with NO anonymous checkDefect A (the leak): with
anonymous_read = true,gate(AuthRead)admits the anonymous principal (env.go:852-856), soPOST /api/v1/ssh-keysexecutesSSHKeyRegistry.Addfor principal name"anonymous"(auth.Anonymous(), internal/server/auth.go:411 — a valid single path segment) and writes a key record as a synthetic principal; a second anonymous caller could then delete it. This is a real anonymous write in oidc+anon-read mode. Fix:AuthWritelevel (or an explicit anonymous check) on both ssh-key mutations.Defect B (inconsistent refusal status):
gate()(env.go:857-874) checksp.Anonymous && !anonReadFIRST; with anon-read on, an anonymous caller on an AuthWrite/AuthAdmin route falls through to!p.Write/!p.Adminand gets 403 "write access required" instead of 401 "authentication required". Every feature package answers 401; the core api gate answers 403. No write executes either way, but the UI cannot distinguish "sign in" from "you lack permission" and the acceptance matrix cannot be uniform. Fix: ingate(), checkp.Anonymousbefore the write/admin flag checks and answer 401 (+ the existingWWW-Authenticate: Bearerfrom writePlain) whenever anonymous — anonymous can never be "insufficient role", it is unauthenticated.No middleware-level catch-all exists (internal/server/middleware.go — the chain is requestID/canonicalHost/serverHeaders/recoverPanic/cors/refreshSession;
apiServeinjects the principal, router.go:33-42). Enforcement is a per-package convention (requireAuthenticatedcopy-pasted ~10x). Defense in depth: add ONE middleware-level assert in the api lane seam (apiServein internal/server/router.go, where the principal is already resolved and injected) — afterAuthenticate, ifcfg.Server.Auth.Mode == "oidc"and the request is a non-GET/HEAD/OPTIONS (i.e. any state-changing method) ANDp.Anonymous, answer 401 immediately without invoking the handler. This makes the catch-all a property of the identity chain, not of each package remembering to gate. The per-routerequireAuthenticatedcalls stay (they also protectnone-mode-free semantics and keep error messages precise); the middleware is the guarantee. Cite the Resolve contract comment (access.go:295-309): anonymous resolves to read-at-most — the middleware turns that into "and never writes".UI affordances anonymous users see today
nav().showCreate = signedIn(web/src/lib/identity.js:117-118, #466); Login button rendered with/_auth/login?next=<current>(identity.js:104-106, #371). No change needed.StarToggle/WatchTogglerender for anonymous — their GETs succeed (social Counts/ViewerState serve anonymous read; Repo.jsx:407 hides only on fetch failure). Clicking star/watch fires the SDK mutation:core.js:_callsees the 401 and triggers popup auth (openAuthPopup->/api-browser/v1/authenticate, web/sdk/src/core.js:478-489, auth.js:45) instead of any interstitial — the visitor gets a jarring popup that then re-runs the write. Same for the Fork pill label link to/{full}/fork(Fork.jsx defaults owner to "" and renders an unusable form for anonymous).New issue(Issues.jsx:252) andNew pull request(Pulls.jsx:93) render unconditionally — anonymous sees them.canComment = role() !== null(Issue.jsx:52) — butuseRoleresolvesreadfor anonymous on a public repo (perms.jsx:27-29 feeding identity/http_perms.go:57-68, Resolve grants anonymous read on public), so the comment composer and reaction chips render for anonymous; submit 401s -> popup-auth path again.Prescribed pattern: a shared client helper
writeGate(headless,web/src/lib/) that pages consult before rendering or invoking any write affordance: anonymous (me().anonymous === true, or me() null under oidc mode) => render the affordance in a soft-disabled state (same pill/button shape, reduced emphasis, title "Sign in to ...") OR hidden — planner's call per surface, but one pattern everywhere — and on click/submit route to/login-required?next=<action>&action=<label>instead of firing the SDK call. The SDK's 401->popup retry (core.js:478) must NOT fire for these flows: either the pre-flight helper prevents the call entirely (preferred), or a flag opts the write call out of popup auth so the 401 surfaces and routes to the interstitial. Server 401s that reach a page without pre-flight (stale identity, direct API use) hook the same way: a thin wrapper aroundreportError/the mutation catch paths that checkserr.unauthorized+ anonymous identity and navigates to the interstitial with the current URL asnext.The interstitial page (new)
/login-required?next=<path>&action=<label>— a new SPA page (static route registered before/:owner, like/invitations), full-page card in the existing design language, light+dark, mobile-clean at 390px (the #273 rules).action=param, default "this action"), states an account is required, one primaryLog inbutton and one back/cancel link. No emoji art needed; a mirror icon is fine.GET /_auth/login?next=<sanitized next>(internal/server/auth_oidc.go:77 — HMAC state carriesnext, 600 s window,sanitizeNextconfines it to a single leading slash; auth.go #344 disabled-state rendering exists). Login completes and the callback 302s tonext— the attempted action URL — so the user lands exactly where they were (repo page, issue thread, fork form with its query string intact).nextmust be the attempted ACTION URL, not the interstitial's referrer chain.history.back()fallback tonext's page or/explore.Proposed design (summary)
gate()answer 401 for anonymous on AuthWrite/AuthAdmin before the flag check; add the oidc anonymous write-assert middleware inapiServe; confirm the releases/tags localrequireRolecopies map anonymous to 401 (align if not).web/src/lib/writeGate.js(pure, node --test-able like identity.js) + interstitial page + per-affordance wiring (star/watch pills, fork pill label, New issue/pull buttons, composer/reactions, New/OrgNew/Import/Fork forms) + the 401-mutation catch hook opting out of popup auth.Acceptance criteria
/login-required?next=&action=explains the requirement, offers Log in via/_auth/login?next=and returns the user to the attempted action after login (state-carry verified through the OIDC callback)next)References: #345 (public browsing), #371 (navbar login/identity,
/_auth/login?next=), #374 (visibility modes + matrix test conventions), #370 (usernames), #346 (create owner gate), #347 (push guardrails).Fixed by PR #507 (#507): anonymous write interstitial + uniform-401 grid + oidc write-assert middleware + ssh-keys leak closure. All 10 acceptance criteria covered (tests + docs); browser proof open per workspace rules.
Review: PR #507 (fix/issue-502) — adversarial security gate
Verified in scratch worktree
/tmp/pr507@1908744(removed afterward).Main worktree untouched (still clean on
main, read-only throughout exceptgit fetch). No browser drive (per task instructions — tests + reasoning;stated explicitly below). No docker, no live-instance contact (note: something
is listening on
127.0.0.1:8080with/setup→ 403 — I probed it read-onlyvia curl for the smoke-test analysis and touched nothing).
1. ssh-keys leak (defect A) — CLOSED, pinned, self-service preserved
internal/api/sshkeys.go:80-87,124-131— both mutations keepAuthReadbutrefuse
p.Anonymouswith 401 before touching the registry. Read-onlyauthenticated principals still get 201 (
TestAuthedWriteRowsUnchanged502),auth-none still gets 201 (principal is
auth.None(), never anonymous —pinned in
TestSSHKeysAnonLeakClosed502), and the matrix asserts theregistry stays empty after anonymous POST/DELETE. No remaining AuthRead-write
hole in
internal/api: the only other POST-under-AuthRead routes arepolicy/validate,policy/dry-run,settings/validate(pure computation,no writes — and now also 401'd for anonymous by the middleware assert anyway).
2. gate() 401-for-anonymous (defect B) — correct, law-9 clean
internal/api/env.go:862,871—if p.Anonymousnow precedes the flag checkson both AuthWrite/AuthAdmin. All three new refusal paths emit
WWW-Authenticate: Bearer realm="walgit"(gate viawritePlain, ssh-keysvia
writePlain, apiServe explicitly) — asserted per-route in the matrixtest. Law 9 improves: the old 403 fall-through never triggered git-credential
erase; 401 does. No legit flow depended on anonymous-403 (anonymous could do
nothing with it); service accounts carry tokens (non-anonymous, pinned
passthrough in
TestAPIServeOIDCAuthedWritePasses502). Stale-pin flips403→401 in
gaps3_test.go:118,placeholder_edge_test.go:47,profile_test.go:185are exactly this behavior change — legitimate.3. Middleware assert — lawful placement, no breakage found
internal/server/router.go:50-54— fires only when ALL hold: anonymous +Mode == "oidc"+ non-GET/HEAD/OPTIONS, afterAuthenticate(so invalidcredentials still 401 first) and after
identityForward. Checked each risk:OIDC callback/login/logout are GET-only (
authFlow, router.go:204-216) —unaffected. health/setup are separate lanes, not apiServe — unaffected.
none-mode never anonymous — unaffected (comment says so, code confirms:
auth.None()is not anonymous). Preflight OPTIONS excluded;isWriteMethodfail-closes every other verb. Token mode untouched (pinned passthrough).
Inter-instance/service calls use Bearer tokens → non-anonymous → pass.
"Webhooks with secrets": no such inbound path exists —
internal/notifydelivery routes are outbound test-pings (admin-gated, in-seam); CRUD was
already 401/403-gated. All feature routes mount through the
RouteProviderchain (
bind_api.go) →s.api.Serve←apiServe, so the assert genuinelycovers issues/pulls/social/notify/mirror/import/identity/releases/tags —
verified the wiring, not assumed. Two pre-existing out-of-seam notes (NOT
blockers, refused either way, unchanged by this PR):
POST /_events/notifyanonymous → 403 via
requireWrite(auth.go:336), and setup PUT anonymous →403 via
requireAdmin— both pre-date this issue's inventory.4. Releases/tags 401 — verified in code, not assumed
internal/releases/releases.go:362-372andinternal/tags/service.go:380-390:requireRolereturnsErrUnauthorized(→401) for anonymous,ErrForbiddenfor authenticated-but-insufficient. Untouched, as claimed.5. Matrix grid — scoped honestly, covered in combination
internal/api/anonwrite502_test.gogrids anonymous × the 10 seam-packagewrites (repo PUT/DELETE, settings PUT/DELETE, policy PUT/DELETE, ops POST,
profile PUT, ssh-keys POST/DELETE) — all 401 + header pins; authenticated
rows pinned unchanged (read-only→403, admin past-the-gate, read-only
ssh-keys→201, none-mode→201). The wider inventory (issues/pulls/star/watch/
webhooks/releases/tags/mirror/import/…) is covered by the catch-all test
(
api_anon_write502_test.go: POST/PUT/PATCH/DELETE past a counting handler→ 401, handler never runs; GET/HEAD/OPTIONS + authed POST pass through) plus
the pre-existing per-package
requireAuthenticatedtests. Given (3)'swiring verification, that combination closes the criterion.
6. Interstitial — no open redirect, return-to-action intact
web/src/lib/writeGate.js:31-35sanitizeNextClientmirrors the server(
auth_oidc.go): must start with single/,//hostand schemes →/.LoginRequired.jsxdefaultsnext=/,action="this action"; login href isbuilt only from discovery-advertised
login_url+ sanitized next, so noattacker-controlled host ever enters the redirect. Back (
href=next) +Cancel (
history.back()with next-fallback) both present. Static routeregistered before
/:owner(index.jsx:71-74) with the reservationdocumented — same class as
/orgs/new. 390px/themes by reasoning only(
max-w-xl px-4card, sharedcard/btn/muted+dark:variants, nobrowser drive per instructions) — stated, not proven.
7. writeGate wiring — one pattern, logged-in unaffected
Repo star/watch pre-flight +
forkHref()gate (Repo.jsx:203,438,608),New-issue/New-pull hrefs, composer fallbacks + reaction pre-flight
(Issue.jsx/Pull.jsx), submit pre-flight + 401-catch on IssueNew/PullNew/
Fork/New/OrgNew/Import/ReleaseNew.
write401Targetkeys offisAnonymousViewer(me, discovery)+ 401, and/api/v1/mealways emitsanonymous: falsefor authenticated (discovery.go:165-177) — so a logged-inuser with a stale session falls to the existing tray/popup path, never the
interstitial.
noPopupAuthdefault keeps the popup-retry path byte-identical(
core.js:478-499); opt-out threads through every writer's existing optsspread.
Import.jsxkeeps a localanonymous()mirroring the same verdict— duplication smell, same fail-closed direction, not worth a round-trip.
8-9. Coverage, hygiene, docs, deps — all green
internal/api95.3% /internal/server98.4% (≥95% gate holds),-raceclean on api/server/identity/releases/tags,node --test web/test/unit/*.test.js1121 pass / 1 fail — the 1 failure isthe
smoke.test.jslive-server subtest, reproduced on pristinemain(pass 2 / fail 1; the :8080 occupant serves
/setup→ 403), i.e.pre-existing and environmental.
vite build+esbuild(run directly vianode_modules/.bin, pnpm shim is broken inside worktrees — environmental)green;
go build ./...,gofmtclean,go vetclean. No manifest changes(
go.mod/package.jsonuntouched — zero new deps). Docs amended asspecified:
06_server_http.md§8.6 + Decisions,12_web_ui.md§1.2 carve-outfeatures/01§4.1 + Decisions.Recommendation: READY TO MERGE
No fixes pushed (nothing structural found; the two nits above are
pre-existing/out-of-scope). Do not merge from this review — merging was
explicitly out of scope.
Fixed by PR #507 (review clean — all 9 adversarial checks pass, leak closed, middleware lawful, matrix green), merged. Closing.