Skip to content

fix(security): allowlist mendys marketplace/live as social-broker return origins - #839

Merged
claude[bot] merged 3 commits into
masterfrom
claude/issue-352-social-broker-origin-allowlist
Aug 27, 2026
Merged

fix(security): allowlist mendys marketplace/live as social-broker return origins#839
claude[bot] merged 3 commits into
masterfrom
claude/issue-352-social-broker-origin-allowlist

Conversation

@claude

@claude claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #352. MendysRobotics' marketplace/live marketplace SPAs delegate sign-in entirely to FuzeFront's server-brokered Google flow (GET /api/v1/security/social/{provider}/start → Google → /social/{google/}callback) and proxy /api/v1/security/* same-origin to fuzefront-security:3002. Today the broker's final redirect always targets appBaseUrl() — the single ambient identity tenant's FRONTEND_URL (app.fuzefront.com in prod) — regardless of which origin actually started the flow, so the mendys SPA never receives its ?code=.

Mechanism (and why it's this one, not the multi-tenant registry)

  • This repo already has a host-based allowlist: SECURITY_TENANTS (tenants.ts), which resolves an inbound request's Host to an entire separate identity tenant (its own Authentik instance, OIDC client, admin token) and fail-closed rejects any undeclared host the moment it's set. I deliberately did not reuse it here: turning it on would require enumerating every host that legitimately reaches security-service today, including in-cluster Service-DNS callers (provisioning-service, config-service, selection-list-service all call fuzefront-security:3002/api/v1/security/*, mounted behind the same tenantContext middleware) — or those callers start getting hard-400'd. There's already a test fixture (tests/tenant-session-roundtrip.test.ts) modelling a full "mendys" tenant on its own Authentik instance for a future white-label epic, but that instance (authentikMendys) is not deployed in prod (enabled: false, no AUTHENTIK_MENDYS_* secrets sealed) — wiring SECURITY_TENANTS now would point at infra that doesn't exist. That's a separate, larger, currently-blocked effort; Social-broker return-origin allowlist: marketplace.mendysrobotics.com + live.mendysrobotics.com #352 only asks for the return origin, so this PR leaves tenant/identity-backend resolution completely untouched.
  • Instead: a new, narrow, additive-only allowlist — backend/security/src/providers/authentik/socialReturnOrigins.ts, env SECURITY_SOCIAL_RETURN_ORIGINS — resolved from the raw Host header at /social/:provider/start (same signal tenants.ts uses, for the same reason: X-Forwarded-Host is caller-supplied), carried through the broker's existing server-side state map to the callback, and consulted only when building the final redirect Location.
  • Exact origin match only — no wildcard, no subdomain, no startsWith/substring match. The destination receives a fresh single-use sign-in code, so an over-broad allowlist here is a direct open-redirect / auth-code-leak vector.
  • Unset/no-match = today's appBaseUrl()-only behaviour, byte for byte. Verified via helm template that a deployment without securityService.socialReturnOrigins set renders zero references to the new env var.
  • A consent-denial or a mid-exchange failure also returns to the right origin (peekSocialReturnOrigin, a non-consuming read of the pending state), not just the happy path.
  • Wired into values-prod.yaml with exactly the two origins named in the issue: https://marketplace.mendysrobotics.com and https://live.mendysrobotics.com.

Test plan

  • New unit tests for the allowlist (socialReturnOrigins.test.ts): exact-match ACCEPT for both mendys origins; REJECT for an unlisted origin, a subdomain of an allowlisted host, and a prefix/suffix attack (marketplace.mendysrobotics.com.evil.com, notmarketplace.mendysrobotics.com); malformed config skipped safely.
  • Provider-level threading tests in google-brokered-signin.test.ts (returnOrigin start→callback, peekSocialReturnOrigin non-consuming).
  • Route-level tests in security-routes.test.ts proving the Host header drives what's passed to the provider and what the callback redirects to, including the HTTP-layer rejection case and the optional-method fallback.
  • helm template / helm lint against values.yaml + values-prod.yaml — new env renders only in prod, with the two mendys origins.
  • Full backend/security jest suite run locally (571 pre-existing passing tests unaffected; the handful of pre-existing failures — security-api/*.contract.test.ts MockIdentityProvider drift, an unrelated authentik-provider.test.ts notification-mock flake, DB-dependent integration suites — reproduce identically on unmodified master via git stash, so nothing here is a regression from this PR). Fixed one unrelated stale assertion in google-brokered-signin.test.ts (missing 4th iss arg) in passing, since this PR already touches that file.

🤖 Generated with Claude Code

https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc


Generated by Claude Code

…urn origins (#352)

MendysRobotics' marketplace/live SPAs delegate sign-in to FuzeFront's
server-brokered Google flow but never received the SPA back: the broker's
success/error redirect always targets appBaseUrl() (the single ambient
identity tenant's FRONTEND_URL, app.fuzefront.com in prod), regardless of
which origin actually started the flow.

Mechanism and why it's safe:

- This repo already has a host-based allowlist mechanism (SECURITY_TENANTS /
  tenants.ts) that fail-closed-rejects any undeclared Host. It was NOT reused
  here: turning it on requires enumerating every host that legitimately
  reaches security-service in prod today, including in-cluster Service-DNS
  callers (provisioning-service, config-service, selection-list-service all
  hit fuzefront-security:3002/api/v1/security/* under the same tenantContext
  middleware) or those callers start getting hard-400'd the moment
  SECURITY_TENANTS is set. A pre-existing test fixture
  (tests/tenant-session-roundtrip.test.ts) already models a full "mendys"
  identity tenant on its own Authentik instance for a future white-label
  epic, but that instance (authentikMendys) is not deployed in prod
  (authentikMendys.enabled stays false, no AUTHENTIK_MENDYS_* secrets sealed)
  — wiring SECURITY_TENANTS now would depend on infra that doesn't exist yet.
  That is a separate, larger, currently-blocked effort; #352 only asks for
  the social broker's return origin, so this PR does not touch tenant
  resolution or identity backend selection at all.

- Instead: a new, narrow, additive-only allowlist
  (providers/authentik/socialReturnOrigins.ts, env
  SECURITY_SOCIAL_RETURN_ORIGINS) resolved from the raw Host header at
  /social/:provider/start (same signal tenants.ts uses, for the same reason
  — X-Forwarded-Host is caller-supplied), carried through the broker's
  existing server-side state map to the callback, and consulted ONLY when
  building the final redirect Location. EXACT origin match — no wildcard, no
  subdomain, no startsWith/substring match (open-redirect / auth-code-leak
  surface). Unset/no-match = today's appBaseUrl()-only behaviour, byte for
  byte; every deployment without SECURITY_SOCIAL_RETURN_ORIGINS set is
  unaffected (verified: default helm render carries zero references to the
  new env var).

- Wired into values-prod.yaml with exactly the two origins named in the
  issue: https://marketplace.mendysrobotics.com and
  https://live.mendysrobotics.com. `helm template`/`helm lint` verified.

- A pending consent-denial or a mid-exchange failure also returns to the
  right origin (peekSocialReturnOrigin, a non-consuming read of the pending
  state) rather than only the happy path.

Tests: new unit coverage for the allowlist itself (exact-match ACCEPT for
both origins, and REJECT for an unlisted origin, a subdomain, and a
prefix/suffix attack against an allowlisted host), provider-level threading
tests, and route-level tests proving the Host header actually drives what
gets passed to the provider and what the callback redirects to (including
the rejection case at the HTTP layer). Also fixed one unrelated stale
assertion in google-brokered-signin.test.ts (missing 4th `iss` arg) that
was failing identically on unmodified master, in a file this PR already
touches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@claude
claude Bot requested a review from izzywdev as a code owner August 27, 2026 08:18
@claude claude Bot added the auto-merge Enable squash auto-merge once CI passes label Aug 27, 2026
gate-identifier failed on this PR with 8 violations — 4 "'id' is minted as
a bare uuid" plus 4 "stale source exemption ... that line no longer mints
an id", all in AuthentikIdentityProvider.ts.

Neither is a new identifier defect. This PR inserts 23 lines above the
existing mint sites, and governance/identifier-allowlist.txt exempts them
by LINE NUMBER, so every exemption drifted off its target by exactly +23:

    662  -> 685    email_verifications row
    885  -> 908    mfa_recovery_codes row
    976  -> 999    password_resets row
    1082 -> 1105   email_verifications row

The gate is behaving correctly and is worth keeping strict: it refuses to
let a stale line number silently exempt whatever code drifts under it —
"a drifting line number exempts code nobody reviewed" — and reports both
halves (the now-unexempted mint site AND the exemption pointing at
nothing) rather than just going quiet.

Re-pointed each to its real site, which is what the gate's own message
prescribes. Every mapping was verified against the table the site inserts
into rather than assumed from the uniform +23 offset:

    685  -> this.db('email_verifications').insert({ id: uuidv4(), ... })
    908  -> this.db('mfa_recovery_codes').insert(... id: uuidv4() ...)
    999  -> this.db('password_resets').insert({ id: uuidv4(), ... })
    1105 -> this.db('email_verifications').insert({ id: uuidv4(), ... })

No exemption was added, removed, or widened; the same four sites are
exempt for the same four recorded reasons.

Verified: `python3 scripts/gate_identifier.py .` reproduced the 8
violations before the change and reports `gate-identifier: OK` after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@github-actions
github-actions Bot enabled auto-merge (squash) August 27, 2026 08:41
Conflict in deploy/helm/fuzefront/values-prod.yaml. Both sides ADD distinct,
non-overlapping content under `securityService:` at the same point:

  ours   (#352) — the live `socialReturnOrigins` allowlist for MendysRobotics'
                  marketplace/live SPAs
  theirs (#835, now on master) — the commented-out, NOT-YET-ACTIVE second
                  identity tenant block

Neither replaces the other and neither loses behaviour if both are kept, so this
is not a pick-one resolution: master's documentation block first, then this
branch's live list.

Verified by rendering rather than by eye, since a YAML conflict resolution that
merely parses can still be structurally wrong:

  helm template ... -f values-prod.yaml   -> OK, 61 resources
  SECURITY_SOCIAL_RETURN_ORIGINS = "https://marketplace.mendysrobotics.com,https://live.mendysrobotics.com"
  SECURITY_TENANTS                -> still absent (single-tenant mode preserved)

so #352's feature survives the merge and #835's tenant work stays inert, which is
what its own comment says it must be until FuzeInfra#421 is live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013tMciHkPE8To7V67CsgKKc
@claude
claude Bot merged commit 2d50663 into master Aug 27, 2026
62 checks passed
@claude
claude Bot deleted the claude/issue-352-social-broker-origin-allowlist branch August 27, 2026 16:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge Enable squash auto-merge once CI passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Social-broker return-origin allowlist: marketplace.mendysrobotics.com + live.mendysrobotics.com

1 participant