diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md index e9196af2..d173f0ab 100644 --- a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md +++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-SECURITY-REVIEW.md @@ -4,7 +4,7 @@ **Level:** OWASP ASVS L1 (RESEARCH "Security Domain"), block on high; D-11 asks for this review of the matching and state code. **Scope:** `cooler.Cache.Pull` (summercms.go `modules/cooler`), this phase's sm-user-plugin additions (provider source, row-locked unlink, exported sign-in helpers), the new `sm-sociallogin-plugin` (registry, goth wrappers, state cookie, callback rules, one-time codes, matching, registration, linking, settings), the adoption in sm-demo-app, sm-bm-app and fonoteka.go, and this plan's gate. **Threats reviewed:** T-19.5-01..T-19.5-37 and T-19.5-SC from the threat models of plans 01, 02, 03 and 04, plus RESEARCH's Security Domain table. -**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason (process controls). Two defects found by the new tests were fixed in their own commits (one of them on T-19.5-12). One new finding, T-19.5-38 (pre-account takeover through an account created from an unverified provider email, the PHP behaviour D-10 ported), is **open: it needs a decision** and is raised with the plan's checkpoint. +**Result:** every threat is closed by a test that fails when the mitigation is removed, or accepted with a reason (process controls). Two defects found by the new tests were fixed in their own commits (one of them on T-19.5-12). One new finding, T-19.5-38 (pre-account takeover through an account created from an unverified provider email, the PHP behaviour D-10 ported), was raised at the Task 6 checkpoint; the user chose option (b), and it is closed by commit d7ee28d with `TestPreAccountTakeover`. Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **S** = `sm-sociallogin-plugin`, **D** = sm-demo-app, **B** = sm-bm-app (with `plugins/jz/bm` and `frontend/`), **A** = the fonoteka.go workspace. @@ -21,8 +21,9 @@ Repositories and abbreviations: **F** = summercms.go, **U** = sm-user-plugin (`f |--------|------|--------|----------|-----| | 21f3fe5 | S | T-19.5-12 | `TestRegisterComplete/concurrent_submit_one_winner` | Two submissions of one pending code that both reached account creation raced on the `users.email` unique index: the loser answered **500** instead of 410. Account creation now takes transaction-scoped advisory locks on the provider identity and the email (`lockCreation`), and a completion that gets them after a winner committed finds the identity and returns `ErrPendingConsumed` (410), writing nothing. | | c99f5f9 | S | T-19.5-16 (settings write path) | `TestSettings/admin_json_hides_secrets` | Every save of the admin settings page answered 500: `Settings.Rules()` used `regex:`, which lagoon's model validation does not support, so the encrypted secrets could not be managed from the admin at all. The tenant format moved to `Settings.BeforeValidate` (422 on `microsoft_tenant`); README updated. | +| d7ee28d | S | T-19.5-38 | the review (finding), pinned by `TestPreAccountTakeover` | A verified sign-in linked onto a password-less account created from an unverified provider email (pre-account takeover). The callback and the pending completion now auto-link a verified email only onto an account with a self-set password (`ownerVerifiedEmail`); otherwise pending `unverified_match` and 422 `email_exists_link_account`. A password reset through the mailed code proves the address. Deliberate divergence from PHP (user decision, option b). | -Both are production changes after plan 02's release (v0.1.0); they reach the applications only through the release the plan's checkpoint asks for (T-19.5-36). +All three are production changes after plan 02's release (v0.1.0); they reach the applications through the v0.1.1 release (T-19.5-36). ## Test pins added by the review @@ -45,7 +46,7 @@ The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register nam | T-19.5-07 | callback (login CSRF, injected code) | Sealed per-browser state cookie; empty state refused; `subtle.ConstantTimeCompare`; provider bound by the cookie name (AAD) and the `Provider` field; cookie cleared on every outcome. | S `controllers/callback.go:27`, `:35-45`; `classes/statecookie.go:134-162` | `TestStateCookie/callback` (missing cookie, empty and unequal state, replay after clear, another provider's callback, cleared on success, provider error, failed exchange, expired code) (S) | closed | | T-19.5-08 | `SealState` / `OpenState` | AES-256-GCM, fresh nonce, HKDF purpose key separate from lagoon's, AAD the cookie name, version and iat (600 s, 60 s skew) checked, 3800-byte bound. | S `classes/statecookie.go:93-129`, `:180-220` | `TestStateCookie/refused` (16 cases), `age_window`, `seal_limits`, `key_domain_separated` (new) (S) | closed | | T-19.5-09 | `NormalizeFrontendCallback`, `NormalizeReturnTo` | Exact-host allow-list, fixed path, the target rebuilt from scheme, host and port; return_to local only; invalid input falls back to `default_callback`. The callback uses the target sealed at the start, never a request value. | S `classes/callback.go:56-107`; `controllers/callback.go:36-39` | `TestNormalizeFrontendCallback` (44 subtests), `TestNormalizeReturnTo` (22 subtests) (S); `TestLinkSmoke/bad_callback_falls_back` (S) | closed | -| T-19.5-10 | email matching (account takeover) | Verified only where the provider asserts it: Google a boolean `verified_email`/`email_verified`, GitHub the primary verified `/user/emails` address, Facebook and Microsoft never; an unverified match goes pending `unverified_match`; case-insensitive match. | S `classes/providers_goth.go:126-254` (Facebook `:154`, Microsoft `:251`); `classes/flow.go:92-101` | `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuthGate` (S) | closed (see T-19.5-38 for the creation path) | +| T-19.5-10 | email matching (account takeover) | Verified only where the provider asserts it: Google a boolean `verified_email`/`email_verified`, GitHub the primary verified `/user/emails` address, Facebook and Microsoft never; an unverified match goes pending `unverified_match`; case-insensitive match. | S `classes/providers_goth.go:126-254` (Facebook `:154`, Microsoft `:251`); `classes/flow.go:92-101` | `TestEmailVerified`, `TestNOAuth` (Microsoft mail of an existing user in four tenants: pending, no identity, completion 422), `TestMatching`, `TestPHPSocialAuthGate` (S) | closed (the creation path: T-19.5-38, closed) | | T-19.5-11 | `RegisterComplete` (pending hijack) | Completion re-checks identity and email match; `email_verified` false or absent gives 422 `email_exists_link_account`, never a login; payloads sealed. | S `classes/flow.go:175-228` | `TestRegisterComplete/email_exists_link_account_keeps_code`, `TestPHPPendingRegistrationTakeover` (incl. a payload sealed without the flag) (S) | closed | | T-19.5-12 | one-time codes (replay, race) | 64-character `crypto/rand` codes sealed under the cache key, consumed with `Pull`; TTLs 5 min, 10 min, 60 s; register-complete decides before it pulls; concurrent completions are serialised (21f3fe5). | S `classes/codes.go:81-224`; `controllers/register_complete.go:80-87`; `classes/registration.go:192` | `TestCodes` (incl. moved values, 32 concurrent pulls), `TestOAuthComplete`, `TestRegisterComplete/concurrent_submit_one_winner` (S) | fixed in commit 21f3fe5 (with its test) | | T-19.5-13 | beta gate (D-12) | Constant-time comparison on both creation paths; the start request's key carried in the sealed cookie and pending payload. | S `classes/registration.go:55-67`; `controllers/register_complete.go:71`, `:116-125` | `TestBetaGate` (S) | closed | @@ -62,7 +63,7 @@ The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register nam | T-19.5-24 | bm SPA fallback | `/oauth` among the reserved prefixes of the static handler and the service worker. | B `plugins/jz/bm/static.go`; `frontend/src/pwa/sw.js` | `TestStaticReservedPaths` (B, gate `--bm`); `pwa.test.ts` (B) | closed | | T-19.5-25 | committed configuration and env templates | Credentials only in `.env` or the encrypted settings. | D, B `config/`, `deploy/` | grep in this review: no non-empty `CLIENT_SECRET=` or `client_secret:` in D or B config and templates | accepted: verified by grep; no runtime behaviour to test | | T-19.5-26 | bm callback view (code in history) | The view replaces the route before redeeming the code once; the server consumes it with `Pull`, so a code left in history is dead. | B `frontend/src/views/OAuthCallbackView.vue`; S `controllers/complete.go:24` | `TestOAuthComplete/once_with_shape` (second use 410) (S); `oauth.test.ts` (B) | closed for the server half; the view's history handling is a browser behaviour left to `/msd-verify-work` (manual row in VALIDATION.md) | -| T-19.5-27 | Microsoft on bm (nOAuth, tenant) | Microsoft never verified, whatever the tenant; existing students link through the ticket. | S `classes/providers_goth.go:225-254` | `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (creation path: T-19.5-38) | +| T-19.5-27 | Microsoft on bm (nOAuth, tenant) | Microsoft never verified, whatever the tenant; existing students link through the ticket. | S `classes/providers_goth.go:225-254` | `TestNOAuth` (four tenants) (S); `TestSocialLoginMounted` (B) | closed (creation path: T-19.5-38, closed) | | T-19.5-28 | link ticket in the bm profile flow | Ticket from an authenticated POST, 60 s, single use, the only value in the URL. | S `controllers/link_ticket.go`; B `frontend/src/lib/oauth.ts` | `TestLinkTicket` (S); `oauth.test.ts` (B) | closed | | T-19.5-29 | pushes of shared repositories (plan 03) | Push only under plan 02's reply, plugin before pointer, after fetch and a range check. | 19.5-03-SUMMARY "Pushes" | release control | accepted: a process control, recorded in 19.5-03-SUMMARY | | T-19.5-30 | fonoteka regression from D-13 | fonoteka's vet and short suite at the new pointer; the Docker-backed user plugin suite. | A | gate `--fonoteka` (F); `TestOAuthIdentitiesDestroyRule` (U) | closed (the unrelated `TestPhase09SecurityRoutes` failure is outside this phase, deferred-items.md) | @@ -71,7 +72,7 @@ The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register nam | T-19.5-33 | completeness of this review | Every T-19.5 ID of plans 01-04 appears here with a test or an accepted reason. | this document | the plan's acceptance count | closed | | T-19.5-34 | test fixtures and logs | Test-only literal secrets; provider HTTP faked through `ProviderConfig.HTTPClient`; the fake transport refuses hosts outside its table and the tests check every request stayed on the provider's hosts. | S `providers_goth_test.go`, `settings_test.go` | `TestGothProviders/fake_transport_refuses_unknown_hosts`, `TestSettings/no_secret_in_logs` (S) | closed | | T-19.5-35 | PHP map honesty | The map is checked against the list, the live PHP files and the Go test sources of S and U. | S `php_method_map_test.go` | `TestPHPMethodMap` (mutation-checked) (S) | closed | -| T-19.5-36 | post-release fixes | The plan's "Post-release fixes" table lists 21f3fe5 and c99f5f9 against every pin that lacks them; the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order and checks every pin with `merge-base --is-ancestor`. | 19.5-04 Tasks 5-7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria (open until the Task 6 reply) | +| T-19.5-36 | post-release fixes | The plan's "Post-release fixes" table lists 21f3fe5, c99f5f9 and d7ee28d against every pin that lacks them; the blocking-human Task 6 asks for the go-ahead; Task 7 releases in D-21 order and checks every pin with `merge-base --is-ancestor`. | 19.5-04 Tasks 5-7 | Task 7's acceptance criteria | release control: Task 6 reply "release"; v0.1.1 and the pointer bumps checked by Task 7's acceptance criteria (19.5-04-SUMMARY) | | T-19.5-37 | remote operations of Task 7 | Only after the Task 6 reply; fetch and range check before every push; never forced or deployed; only `plugins/golem15/user` staged in fonoteka.go. | 19.5-04 Task 7 | Task 7's acceptance criteria | release control: recorded with Task 7's acceptance criteria | | T-19.5-SC | module installs | goth v1.82.0 approved in plan 01 Task 4; no module installed in plan 04 (S `go.mod` and `go.sum` unchanged since adfcfbf); goth imported only by `classes/providers_goth.go`; no gorilla/sessions or gopkg.in/yaml.v3 in `go list -deps .`. | S `go.mod`; `classes/providers_goth.go:13-18` | gate `--sociallogin` (deps and importer checks) (F) | closed | @@ -79,7 +80,7 @@ The rest of the pins are this plan's unit tests (Tasks 2 to 4); the register nam | Threat | Category | Component | Severity | Description | Status | |--------|----------|-----------|----------|-------------|--------| -| T-19.5-38 | Elevation of privilege | account creation from an unverified provider email (`HandleCallback` with `action=register`, `CompletePending`) | high | An account is created **activated** with an email the provider did not verify (Facebook, Microsoft, a Google answer without the flag). An attacker who controls such a provider account with a victim's address (any Entra tenant can set `mail`; nOAuth) creates the account before the victim does. Later the victim signs in with a verifying provider (Google, GitHub): the verified match links that identity to the attacker's account, and the attacker keeps access through the original identity ("pre-account takeover", classic-federated merge). On a Microsoft-only site (bm) the victim cannot sign in through Microsoft (unverified match), resets the password through the mailbox and shares the account with the attacker. PHP's SocialAuth behaves the same (D-10 ports it), so this is not a regression; it is open because a fix changes D-10 behaviour. Options: (a) create accounts from an unverified email not activated and send golem15.user's activation mail (the owner proves the mailbox before any sign-in or link); (b) refuse the verified-match auto-link onto an account whose email was never verified by its owner (no self-set password, only unverified identities); (c) accept for applications whose providers all verify, documenting it in the README. | open: decision needed (raised at the Task 6 checkpoint; not fixed in this plan, since a fix changes D-10) | +| T-19.5-38 | Elevation of privilege | account creation from an unverified provider email (`HandleCallback` with `action=register`, `CompletePending`) | high | An account is created **activated** with an email the provider did not verify (Facebook, Microsoft, a Google answer without the flag). An attacker who controls such a provider account with a victim's address (any Entra tenant can set `mail`; nOAuth) creates the account before the victim does; when the victim later signs in with a verifying provider (Google, GitHub), the verified match linked that identity onto the attacker's account, and the attacker kept access through the original identity (pre-account takeover, classic-federated merge). PHP's SocialAuth behaves the same (D-10). Mitigation (user decision at the Task 6 checkpoint, option b, a deliberate divergence from PHP): the callback and the pending completion auto-link a verified email only onto an account with a self-set password (S `classes/flow.go` `ownerVerifiedEmail`, used in `HandleCallback` and `CompletePending`); a password-less account goes pending `unverified_match` and its completion answers 422 `email_exists_link_account`; the owner proves the address with the mailed password reset (which sets `has_self_set_password`), after which verified providers link again. Test: `TestPreAccountTakeover` (S; mutation-checked: an always-true `ownerVerifiedEmail` fails it). | fixed in commit d7ee28d (with its test) | ## Log calls diff --git a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md index a67996b2..78231828 100644 --- a/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md +++ b/.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-VALIDATION.md @@ -78,7 +78,7 @@ Decision coverage (from RESEARCH § Validation Architecture): | D-06/D-17 | callback and `return_to` normalisation tables | `TestNormalizeFrontendCallback`, `TestNormalizeReturnTo` (S) | `--sociallogin` | ✅ green | | D-09 | complete 422/410/200/second-use 410; cookie mode sets the token cookie | `TestOAuthComplete`, `TestTokenCookieMode` (S) | `--docker` | ✅ green | | D-09 | register-complete 422 consent keeps the code; 410; `email_exists_link_account`; 16 concurrent submits → one 200 | `TestRegisterComplete` (S) | `--docker` | ✅ green | -| D-10 | matching branch table, soft-deleted, inactive and banned | `TestMatching`, `TestSoftDeleted`, `TestInactiveAndBanned` (S) | `--docker` | ✅ green | +| D-10 | matching branch table, soft-deleted, inactive and banned, no auto-link onto a password-less account (T-19.5-38) | `TestMatching`, `TestSoftDeleted`, `TestInactiveAndBanned`, `TestPreAccountTakeover` (S) | `--docker` | ✅ green | | D-11 | email-verified per provider, nOAuth case | `TestEmailVerified` (S, `--sociallogin`), `TestNOAuth` (S, `--docker`) | `--sociallogin`, `--docker` | ✅ green | | D-12 | beta gate off/wrong/pending path | `TestBetaGate` (S) | `--docker` | ✅ green | | D-14 | link via bearer/`token`/`auth_token`/ticket; no JWT in URLs; owned by another user | `TestLink`, `TestLinkTicket` (S) | `--docker` | ✅ green |