Repository navigation
Project Zero Trust accepts Access policies that let in anyone with a One-time PIN or public login #429
Description
Problem
Project Zero Trust accepts an Access policy whose only identity check is a One-time PIN, a public login (GitHub, consumer Google, an unrestricted Cloudflare login), or a login added with the generic OpenID Connect or SAML connector. Such a policy lets in anyone who has an email address or such an account, so the "protected" apps are public. Cloudflare names Include | Login Methods | One-time PIN next to Include | Everyone as the two settings that let anyone access an application.
installer (platform protection, app access) ──▶ rejects OTP-only (#424)
Admin → Zero Trust "Save and reconcile" ──▶ accepts OTP-only
void platform zero-trust configure ──▶ accepts OTP-only
status --check / "Check with Cloudflare" ──▶ "Healthy", also after a policy is edited to Everyone
- The server passes an "any login method" rule to the shared check (
platform/packages/api/src/zero-trust/cloudflare.ts). This has been the behavior since project Zero Trust shipped (feat(platform): add project Zero Trust protection #335). - Policy rejections are plain errors. The operator API maps them to
502 Cloudflare Zero Trust reconciliation failed, and domain activation maps them to503 Retry domain setup. The operator never sees the reason. - Access apps reference policies by ID, and Cloudflare evaluates the live policy. A check passes only at the moment it runs. A later edit in Cloudflare is not reported.
Where a selection is checked today:
| Path | Code |
|---|---|
| configure (Admin UI, CLI) | zero-trust/installation.ts configurePlatformZeroTrust → validateSelection |
| reconcile, hourly resume, domain activation | zero-trust/operation.ts runConfigureOperation (from installation.ts reconcile, configure, domain) |
| status --check | zero-trust/status.ts |
| installer readiness | platform-app-access.ts (needs live.present && !live.drifted) |
Decision
Check strictly when someone submits settings. Stay lenient when the server re-checks saved settings. Report problems with saved settings as a warning.
submit (Admin Save, zero-trust configure, installer handoff) ─▶ STRICT → 409 + reason
exception: only the token changes (same IdPs, policies, defaults) ─▶ lenient
background re-check (reconcile, resume, domain activation) ─▶ LENIENT (unchanged)
status --check / "Check with Cloudflare" ─▶ lenient + policyWarning (never changes live)
Rejecting during background re-checks closes nothing, because Cloudflare keeps serving the live policy. It would only stop reconcile, domain changes and new protected projects. A strict check on submit costs an affected admin one policy edit, at a time when they are already changing the settings.
Design
cloudflare.ts
validateSelection(input, check: 'submit' | 'saved' | 'report' = 'saved')returns{ teamDomain, policyWarning? }.submit: alogin_methodrule counts only whenisPlatformAccessCompanyIdentityProvider(provider)is true (the installer's rule). Since fix: use the Void Cloudflare login in dev and unblock self-hosted platform onboarding #424 this means an IdP type whose required settings name one tenant: Entra ID, Okta, OneLogin, PingOne, Centrify, Google Workspace, or Cloudflare with Restrict to account members. Genericoidcandsamldo not count.saved: today's behavior.report: runsaved(failures throw as today), then the strict rule on the policies already fetched. This pass is best effort: a policy rejection becomespolicyWarning, and any other error (429, 5xx) is logged and gives no warning. It never reaches the status catch.
- Provider lookup: seed a map with the selected IdPs that are already fetched. Fetch each other
login_methodIdP once by ID. A 404 means the IdP was deleted. A policy with nologin_methodrule makes no extra call. Share one group cache between the two passes. - Every policy-shape throw becomes
ZeroTrustPolicyRejectedError. "Cloudflare returned an invalid …" stays a plainError.
errors.ts: ZeroTrustPolicyRejectedError extends ZeroTrustUnavailableError (409). It lives here to avoid an import cycle with cloudflare.ts.
installation.ts: use saved only when the token is the only change: (previous.enabled || previous.operation) and the same IdP IDs, policy IDs, protectNewProjects and publicAccess, and no existingProjects. Otherwise use submit. In the claim callback, if the exception applied but the saved operation changed meanwhile, throw ZeroTrustUnavailableError('The saved Zero Trust operation changed. Retry configuration.').
status.ts: pass report and add policyWarning: string | null. In the existing catch, set policyWarning to the message when the error is a ZeroTrustPolicyRejectedError (a saved policy that was edited to Everyone or Bypass), and keep live exactly as it is today.
Messages
Access policy "<name>" lets in anyone who can sign in with <IdP name>. In Cloudflare, add a Require rule such as Emails ending in your company domain, or select another policy.The text says Require, not Include: Include rules are OR, so an extra Include still lets in anyone with OTP.- For a group:
Access group "<name>" lets in …with the same ending. - Generic OIDC or SAML login:
Access policy "<name>" relies only on <IdP name>, a generic OIDC or SAML login, and Void cannot tell who can sign in with it. In Cloudflare, add a Require rule such as Emails ending in your company domain, or select another policy. - Deleted IdP:
Access policy "<name>" uses a login method whose identity provider no longer exists (<id>). Fix the policy in Cloudflare, or select another policy. - Cloudflare IdP without "Restrict to account members": end with
turn on Restrict to account members for that login method in Cloudflare, or select another policy.
Surfaces
- Admin "Save and reconcile": the error page shows the message, and nothing is saved.
- Admin "Check with Cloudflare":
<Banner status="warning" title="Access policy is too broad" description={policyWarning} />. "Live application" stays Healthy. zero-trust configure: 409 with the message.--planstays offline.zero-trust status --check: printsPolicy warning: <text>.zero-trust reconcileand domain activation on a policy now rejected for its shape: 409 with the reason, instead of the generic 502 or 503.- No new flag and no new token permission ("Identity Providers … Read" is already required).
Docs
docs/guide/platform/administration/zero-trust.md: list "policies that rely only on One-time PIN, a public login such as GitHub, or a generic OIDC or SAML login" among rejected policies, assetup.mddoes. Add aPolicy warningbullet to the status section.docs/reference/cli/platform-zero-trust.md: the same clause.
Effect on running installations
Serving, reconcile, resume and domain changes keep working. status --check and the Admin page show a warning until the policy is fixed. The next save of Zero Trust settings rejects the policy. Release note: "If you chose Zero Trust policies in Admin or with void platform zero-trust configure, run void platform zero-trust status --check and fix any Policy warning."
Not doing
- Reject during background re-checks: blocks operations and closes nothing.
- Filter pickers: the Admin UI and the CLI take IDs, and the installer pickers already check on submit.
- Migrate or turn off Zero Trust: Void never rewrites customer policies, and turning Zero Trust off makes apps public.
- Fail closed for protected projects: an outage caused by a policy the admin chose.
Tests
- configure with
include: [login_method OTP]→ 409 with the message, nothing saved. Fails today (200). - Cloudflare's recipe (
include email_domain,require login_method OTP) → 200.include [OTP, email_domain](OR) → 409. - Cloudflare IdP:
restrict_to_account_membersfalse → 409, true → 200. Okta login method → 200. - Generic OIDC or SAML login method alone → 409. Generic OIDC Include +
email_domainRequire → 200. login_methodIdP outside the selected IDs → fetched by ID; a 404 → 409 with the "no longer exists" text.- configure with an Everyone policy → 409 with the reason. Fails today (502).
- Saved OTP-only policy: reconcile → 200;
status --check→ live unchanged andpolicyWarningset; a new configure with other settings → 409. - Token-only change with a saved OTP-only policy → 200, with or without a pending operation. Changing the IDs → 409.
- A strict-pass 429 in
report→ no warning, live unchanged. - Domain activation with a saved policy edited to Everyone → 409 with the reason (was 503).
- Admin page shows the banner when
policyWarningis set.
Related, out of scope
- Platform protection policies (
admission.access) are checked only at install. A later OTP-only edit in Cloudflare can open company admission. This needs a separate check. - PingOne's Cloudflare doc example shows type
ping, while the API enum sayspingone. Confirm the live value.duois not on the list on either side. - Cloudflare's "Cloudflare Account Member" selector is not on the company rule list on either side. Its API rule key is unverified.
zero-trust configure --plancould run the same read-only check, since the token is in the request.
Depends on #424 (isPlatformAccessCompanyIdentityProvider in packages/platform/src/access-policy.ts).
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
fix: use the Void Cloudflare login in dev and unblock self-hosted pla…