feat(api): extend CORS_ORIGINS to support https://*.domain patterns #7

Open
opened 2026-08-11 03:45:43 +00:00 by PlasticDigits · 4 comments
PlasticDigits commented 2026-08-11 03:45:43 +00:00 (Migrated from gitlab.com)

Summary

Extend API build_cors / CORS_ORIGINS so operators can allow entire HTTPS subdomain trees (e.g. https://*.cl8y.com) alongside exact origins, without opening CORS to the entire internet (*).

This unblocks browser TermsGate clients on ecosystem dapps (e.g. https://dex.cl8y.com) that currently fail with Unable to verify terms acceptance: Failed to fetch because the production allowlist only reflects https://terms.cl8y.com.


Current codebase

How CORS works today

  • Config: CORS_ORIGINS is a comma-separated list of strings loaded in api/src/config.rs (default https://terms.cl8y.com).
  • Layer: api/src/routes/mod.rs build_cors:
    • If the list is exactly ["*"] → CorsLayer with allow_origin(Any) (fully open).
    • Otherwise each entry is parsed as an exact HeaderValue and passed to allow_origin(origins).
  • There is no subdomain / pattern matching. An entry like *.cl8y.com or https://*.cl8y.com will not match browser Origin: https://dex.cl8y.com.
  • Browser clients (SDK packages/cl8y-clickwrap) call GET {apiBaseUrl}/api/v1/signatures/status cross-origin. Missing Access-Control-Allow-Origin becomes opaque TypeError: Failed to fetch in TermsGate.

Observed production behavior (pre-fix)

Request Origin Access-Control-Allow-Origin echoed?
https://terms.cl8y.com Yes
https://dex.cl8y.com No

API is reachable; CORS deny is what breaks the DEX gate.

  • Portal post-sign return uses exact origins via VITE_REDIRECT_URI_ALLOWLIST (web/src/redirect.ts + SDK sanitizeRedirectUri). That is a separate allowlist; do not fold redirect policy into this CORS change unless explicitly expanded later.
  • Property registration (POST /admin/properties, scripts/register-property.sh) does not grant CORS.

Why this is needed

  1. Operational scale — CL8Y hosts multiple first-party dapps (dex.cl8y.com, future subdomains). Listing every origin in deploy env is brittle and causes silent browser failures when a new host is missed.
  2. Correct CORS model — Spec does not allow responding with Access-Control-Allow-Origin: *.cl8y.com. The server must match a pattern and echo the request Origin. That requires application logic (AllowOrigin::predicate), not a literal wildcard header.
  3. Avoid CORS_ORIGINS=* — Full open CORS is explicitly discouraged for production (see audits / README hygiene). Subdomain patterns are the least-privilege alternative for first-party hosts.

Constraints & guardrails

  1. Scheme-bound patterns only — Supported form: https://*.example.com (and optionally http://*.example.com for local/dev). Bare *.example.com without scheme must be rejected or treated as config error (prefer reject at startup / ignore with clear log — pick one and document).

  2. HTTPS in production — Document that prod should use https://*.cl8y.com, not http://….

  3. Safe host matching — Allow:

    • apex: https://cl8y.com when pattern is https://*.cl8y.com
    • subdomains: https://dex.cl8y.com, https://a.b.cl8y.com

    Reject:

    • suffix spoof: https://cl8y.com.evil.com, https://evilcl8y.com, https://notcl8y.com
    • wrong scheme: http://dex.cl8y.com when only https://*.cl8y.com is configured
    • credentials / null origin abuse: do not allow null Origin via pattern
  4. Ports — Pattern match default ports only unless an exact origin with port is also listed (e.g. http://localhost:5173 remains exact-only). Non-default ports on https://dex.cl8y.com:8443 must not match https://*.cl8y.com unless explicitly decided otherwise; default = deny non-default ports for patterns.

  5. Keep exact origins — Exact entries continue to work and can be mixed with patterns in one CORS_ORIGINS value.

  6. Keep CORS_ORIGINS=* — Existing full-open behavior remains for tests/dev; do not change its meaning.

  7. No credentials widening — Do not introduce Access-Control-Allow-Credentials: true as part of this work unless already required (current layer uses methods/headers Any without credentials cookies for the public API).

  8. Docs only for redirect — Call out that DEX still needs https://dex.cl8y.com on VITE_REDIRECT_URI_ALLOWLIST for return-after-sign; that is not solved by CORS patterns.


Relevant files

Path Role
api/src/routes/mod.rs build_cors — primary change (AllowOrigin::predicate)
api/src/config.rs CORS_ORIGINS parsing (may validate pattern syntax)
api/tests/integration_test.rs Integration fixtures currently use cors_origins: vec!["*"] — add focused CORS cases
.env.example Document pattern syntax + prod example
README.md Operator docs for CORS_ORIGINS
packages/cl8y-clickwrap/README.md Integrator note: origin must still match API CORS policy
audits/INTERNAL_COMPOSER_1786408744.md Prior note: never ship CORS_ORIGINS=* in prod

Optional helper module (if mod.rs grows): e.g. api/src/cors.rs for parse + match unit tests.


  1. Parse each CORS_ORIGINS entry into:

    • Exact(HeaderValue), or
    • Wildcard { scheme: https|http, base_host: "cl8y.com" } for https://*.cl8y.com / http://*.cl8y.com
  2. Build CorsLayer with AllowOrigin::predicate that returns true when the request Origin:

    • equals any exact entry, or
    • parses as url::Url / origin and matches a wildcard rule (scheme, host apex-or-subdomain, default port)
  3. Rely on tower-http to mirror the allowed request Origin into Access-Control-Allow-Origin (do not emit a literal *.host).

  4. Recommended production setting after deploy:

    CORS_ORIGINS=https://*.cl8y.com
    

    (https://terms.cl8y.com is covered by the same pattern.)

  5. Add unit tests for the matcher; add at least one HTTP-level test that OPTIONS/GET from an allowed subdomain receives access-control-allow-origin echoing that origin, and a denied origin does not.


Acceptance criteria

  • CORS_ORIGINS=https://*.cl8y.com allows Origin: https://dex.cl8y.com and echoes that origin on preflight and actual responses.
  • Same config allows apex Origin: https://cl8y.com and existing portal https://terms.cl8y.com.
  • Exact origins still work alone and when mixed with patterns (e.g. https://*.cl8y.com,https://partner.example).
  • CORS_ORIGINS=* behavior unchanged (allow any).
  • Denied origins (wrong scheme, spoof hosts, non-default port under pattern) do not receive Access-Control-Allow-Origin.
  • Invalid pattern syntax is handled safely (no panic on request path; prefer fail-fast at config load or skip-with-error log — documented).
  • .env.example + README document pattern syntax, apex inclusion, and that redirect allowlist is separate.
  • No regression to rate limits, admin auth, or signature status JSON error bodies for same-origin / allowed-origin clients.

Test plan (functional paths)

  1. Exact only (regression)
    Config: https://terms.cl8y.com

    • Allowed: Origin: https://terms.cl8y.com → ACAO echoed
    • Denied: Origin: https://dex.cl8y.com → no ACAO
  2. Wildcard only
    Config: https://*.cl8y.com

    • Allowed: https://dex.cl8y.com, https://terms.cl8y.com, https://cl8y.com, https://a.b.cl8y.com
    • Denied: http://dex.cl8y.com, https://evil.com, https://cl8y.com.evil.com, https://evilcl8y.com
  3. Mixed
    Config: https://*.cl8y.com,https://app.partner.example

    • Both pattern hosts and the exact partner origin allowed; unrelated denied
  4. Full open
    Config: *

    • Any origin still allowed (existing integration tests keep passing)
  5. Preflight + GET

    • OPTIONS and GET /api/v1/signatures/status?... both carry ACAO when Origin is allowed
    • Allowed-origin clients still see JSON errors (e.g. unsupported network) rather than opaque fetch failures
  6. Port edge

    • https://dex.cl8y.com:8443 denied under https://*.cl8y.com (unless exact entry added)
    • Exact http://127.0.0.1:5173 still works for local CI/Playwright
  7. Docs smoke

    • Operator can configure prod from README / .env.example without reading source

Test plan (attack / abuse / hack vectors)

Vector Expectation
Suffix spoof https://cl8y.com.attacker.com No ACAO
Prefix spoof https://evilcl8y.com / https://not-cl8y.com No ACAO
Scheme downgrade http://dex.cl8y.com with https-only pattern No ACAO
Origin: null No ACAO via pattern
Missing / malformed Origin No reflected ACAO; request may still proceed server-side (CORS is browser-enforced)
Pattern smuggling in env (https://*.cl8y.com.evil.com) Matcher only allows that evil base — must not accidentally allow cl8y.com
Attempt to set response ACAO to * or *.cl8y.com via misconfig Implementation must never emit subdomain wildcard as ACAO value; only echo concrete Origin or use existing Any path
Listing * together with other entries Document / define behavior (prefer: only pure ["*"] enables Any; mixed *,https://… should not silently open all — lock this in tests)
High-volume OPTIONS from random Origins Still subject to existing rate limits; CORS deny must not bypass rate limiting
Using CORS success to bypass signature / admin auth CORS is not auth — status/admin checks remain; confirm admin routes still require ADMIN_TOKEN even from allowed Origin

Verification criteria

  1. Unit / integration: Matcher and HTTP CORS tests green in CI for allow + deny matrices above.

  2. Local manual: Run API with CORS_ORIGINS=https://*.cl8y.com and:

    curl -sI -X OPTIONS "http://127.0.0.1:8080/api/v1/signatures/status" \
      -H "Origin: https://dex.cl8y.com" \
      -H "Access-Control-Request-Method: GET"
    # expect: access-control-allow-origin: https://dex.cl8y.com
    
    curl -sI -X OPTIONS "http://127.0.0.1:8080/api/v1/signatures/status" \
      -H "Origin: https://evil.com" \
      -H "Access-Control-Request-Method: GET"
    # expect: no access-control-allow-origin
    
  3. Production after deploy: Same OPTIONS/GET probes against https://api.terms.cl8y.com with Origin: https://dex.cl8y.com show echoed ACAO; loading https://dex.cl8y.com no longer shows Unable to verify terms acceptance: Failed to fetch for network/CORS reasons (subsequent product errors, if any, must be explicit API error strings).

  4. Redirect still checked separately: Signing return to DEX still requires portal allowlist entry — verify docs mention this so CORS fix is not mistaken for full end-to-end sign-return readiness.


Out of scope

  • Implementing portal VITE_REDIRECT_URI_ALLOWLIST wildcards
  • Changing property auto-upsert / admin registration
  • Broadening allow_methods / allow_headers policy beyond current Any
  • Shipping CORS_ORIGINS=* to production
## Summary Extend API `build_cors` / `CORS_ORIGINS` so operators can allow entire HTTPS subdomain trees (e.g. `https://*.cl8y.com`) alongside exact origins, without opening CORS to the entire internet (`*`). This unblocks browser `TermsGate` clients on ecosystem dapps (e.g. `https://dex.cl8y.com`) that currently fail with **Unable to verify terms acceptance: Failed to fetch** because the production allowlist only reflects `https://terms.cl8y.com`. --- ## Current codebase ### How CORS works today - Config: `CORS_ORIGINS` is a comma-separated list of strings loaded in [`api/src/config.rs`](api/src/config.rs) (default `https://terms.cl8y.com`). - Layer: [`api/src/routes/mod.rs`](api/src/routes/mod.rs) `build_cors`: - If the list is exactly `["*"]` → `CorsLayer` with `allow_origin(Any)` (fully open). - Otherwise each entry is parsed as an exact `HeaderValue` and passed to `allow_origin(origins)`. - There is **no** subdomain / pattern matching. An entry like `*.cl8y.com` or `https://*.cl8y.com` will not match browser `Origin: https://dex.cl8y.com`. - Browser clients (SDK [`packages/cl8y-clickwrap`](packages/cl8y-clickwrap)) call `GET {apiBaseUrl}/api/v1/signatures/status` cross-origin. Missing `Access-Control-Allow-Origin` becomes opaque `TypeError: Failed to fetch` in [`TermsGate`](packages/cl8y-clickwrap/src/react/TermsGate.tsx). ### Observed production behavior (pre-fix) | Request `Origin` | `Access-Control-Allow-Origin` echoed? | |------------------|----------------------------------------| | `https://terms.cl8y.com` | Yes | | `https://dex.cl8y.com` | No | API is reachable; CORS deny is what breaks the DEX gate. ### Related (out of scope for this issue) - Portal post-sign return uses **exact** origins via `VITE_REDIRECT_URI_ALLOWLIST` ([`web/src/redirect.ts`](web/src/redirect.ts) + SDK `sanitizeRedirectUri`). That is a separate allowlist; do **not** fold redirect policy into this CORS change unless explicitly expanded later. - Property registration (`POST /admin/properties`, `scripts/register-property.sh`) does **not** grant CORS. --- ## Why this is needed 1. **Operational scale** — CL8Y hosts multiple first-party dapps (`dex.cl8y.com`, future subdomains). Listing every origin in deploy env is brittle and causes silent browser failures when a new host is missed. 2. **Correct CORS model** — Spec does not allow responding with `Access-Control-Allow-Origin: *.cl8y.com`. The server must **match a pattern** and **echo the request Origin**. That requires application logic (`AllowOrigin::predicate`), not a literal wildcard header. 3. **Avoid `CORS_ORIGINS=*`** — Full open CORS is explicitly discouraged for production (see audits / README hygiene). Subdomain patterns are the least-privilege alternative for first-party hosts. --- ## Constraints & guardrails 1. **Scheme-bound patterns only** — Supported form: `https://*.example.com` (and optionally `http://*.example.com` for local/dev). Bare `*.example.com` without scheme must be rejected or treated as config error (prefer reject at startup / ignore with clear log — pick one and document). 2. **HTTPS in production** — Document that prod should use `https://*.cl8y.com`, not `http://…`. 3. **Safe host matching** — Allow: - apex: `https://cl8y.com` when pattern is `https://*.cl8y.com` - subdomains: `https://dex.cl8y.com`, `https://a.b.cl8y.com` Reject: - suffix spoof: `https://cl8y.com.evil.com`, `https://evilcl8y.com`, `https://notcl8y.com` - wrong scheme: `http://dex.cl8y.com` when only `https://*.cl8y.com` is configured - credentials / null origin abuse: do not allow `null` Origin via pattern 4. **Ports** — Pattern match default ports only unless an exact origin with port is also listed (e.g. `http://localhost:5173` remains exact-only). Non-default ports on `https://dex.cl8y.com:8443` must **not** match `https://*.cl8y.com` unless explicitly decided otherwise; default = deny non-default ports for patterns. 5. **Keep exact origins** — Exact entries continue to work and can be mixed with patterns in one `CORS_ORIGINS` value. 6. **Keep `CORS_ORIGINS=*`** — Existing full-open behavior remains for tests/dev; do not change its meaning. 7. **No credentials widening** — Do not introduce `Access-Control-Allow-Credentials: true` as part of this work unless already required (current layer uses methods/headers `Any` without credentials cookies for the public API). 8. **Docs only for redirect** — Call out that DEX still needs `https://dex.cl8y.com` on `VITE_REDIRECT_URI_ALLOWLIST` for return-after-sign; that is not solved by CORS patterns. --- ## Relevant files | Path | Role | |------|------| | [`api/src/routes/mod.rs`](api/src/routes/mod.rs) | `build_cors` — primary change (`AllowOrigin::predicate`) | | [`api/src/config.rs`](api/src/config.rs) | `CORS_ORIGINS` parsing (may validate pattern syntax) | | [`api/tests/integration_test.rs`](api/tests/integration_test.rs) | Integration fixtures currently use `cors_origins: vec!["*"]` — add focused CORS cases | | [`.env.example`](.env.example) | Document pattern syntax + prod example | | [`README.md`](README.md) | Operator docs for `CORS_ORIGINS` | | [`packages/cl8y-clickwrap/README.md`](packages/cl8y-clickwrap/README.md) | Integrator note: origin must still match API CORS policy | | [`audits/INTERNAL_COMPOSER_1786408744.md`](audits/INTERNAL_COMPOSER_1786408744.md) | Prior note: never ship `CORS_ORIGINS=*` in prod | Optional helper module (if `mod.rs` grows): e.g. `api/src/cors.rs` for parse + match unit tests. --- ## Recommended direction 1. Parse each `CORS_ORIGINS` entry into: - `Exact(HeaderValue)`, or - `Wildcard { scheme: https|http, base_host: "cl8y.com" }` for `https://*.cl8y.com` / `http://*.cl8y.com` 2. Build `CorsLayer` with `AllowOrigin::predicate` that returns true when the request Origin: - equals any exact entry, **or** - parses as `url::Url` / origin and matches a wildcard rule (scheme, host apex-or-subdomain, default port) 3. Rely on tower-http to **mirror** the allowed request Origin into `Access-Control-Allow-Origin` (do not emit a literal `*.host`). 4. Recommended production setting after deploy: ```bash CORS_ORIGINS=https://*.cl8y.com ``` (`https://terms.cl8y.com` is covered by the same pattern.) 5. Add unit tests for the matcher; add at least one HTTP-level test that OPTIONS/GET from an allowed subdomain receives `access-control-allow-origin` echoing that origin, and a denied origin does not. --- ## Acceptance criteria - [ ] `CORS_ORIGINS=https://*.cl8y.com` allows `Origin: https://dex.cl8y.com` and echoes that origin on preflight and actual responses. - [ ] Same config allows apex `Origin: https://cl8y.com` and existing portal `https://terms.cl8y.com`. - [ ] Exact origins still work alone and when mixed with patterns (e.g. `https://*.cl8y.com,https://partner.example`). - [ ] `CORS_ORIGINS=*` behavior unchanged (allow any). - [ ] Denied origins (wrong scheme, spoof hosts, non-default port under pattern) do **not** receive `Access-Control-Allow-Origin`. - [ ] Invalid pattern syntax is handled safely (no panic on request path; prefer fail-fast at config load or skip-with-error log — documented). - [ ] `.env.example` + README document pattern syntax, apex inclusion, and that redirect allowlist is separate. - [ ] No regression to rate limits, admin auth, or signature status JSON error bodies for same-origin / allowed-origin clients. --- ## Test plan (functional paths) 1. **Exact only (regression)** Config: `https://terms.cl8y.com` - Allowed: `Origin: https://terms.cl8y.com` → ACAO echoed - Denied: `Origin: https://dex.cl8y.com` → no ACAO 2. **Wildcard only** Config: `https://*.cl8y.com` - Allowed: `https://dex.cl8y.com`, `https://terms.cl8y.com`, `https://cl8y.com`, `https://a.b.cl8y.com` - Denied: `http://dex.cl8y.com`, `https://evil.com`, `https://cl8y.com.evil.com`, `https://evilcl8y.com` 3. **Mixed** Config: `https://*.cl8y.com,https://app.partner.example` - Both pattern hosts and the exact partner origin allowed; unrelated denied 4. **Full open** Config: `*` - Any origin still allowed (existing integration tests keep passing) 5. **Preflight + GET** - `OPTIONS` and `GET /api/v1/signatures/status?...` both carry ACAO when Origin is allowed - Allowed-origin clients still see JSON errors (e.g. unsupported network) rather than opaque fetch failures 6. **Port edge** - `https://dex.cl8y.com:8443` denied under `https://*.cl8y.com` (unless exact entry added) - Exact `http://127.0.0.1:5173` still works for local CI/Playwright 7. **Docs smoke** - Operator can configure prod from README / `.env.example` without reading source --- ## Test plan (attack / abuse / hack vectors) | Vector | Expectation | |--------|-------------| | Suffix spoof `https://cl8y.com.attacker.com` | No ACAO | | Prefix spoof `https://evilcl8y.com` / `https://not-cl8y.com` | No ACAO | | Scheme downgrade `http://dex.cl8y.com` with https-only pattern | No ACAO | | `Origin: null` | No ACAO via pattern | | Missing / malformed Origin | No reflected ACAO; request may still proceed server-side (CORS is browser-enforced) | | Pattern smuggling in env (`https://*.cl8y.com.evil.com`) | Matcher only allows that evil base — must not accidentally allow `cl8y.com` | | Attempt to set response ACAO to `*` or `*.cl8y.com` via misconfig | Implementation must never emit subdomain wildcard as ACAO value; only echo concrete Origin or use existing `Any` path | | Listing `*` together with other entries | Document / define behavior (prefer: only pure `["*"]` enables Any; mixed `*,https://…` should not silently open all — lock this in tests) | | High-volume OPTIONS from random Origins | Still subject to existing rate limits; CORS deny must not bypass rate limiting | | Using CORS success to bypass signature / admin auth | CORS is not auth — status/admin checks remain; confirm admin routes still require `ADMIN_TOKEN` even from allowed Origin | --- ## Verification criteria 1. **Unit / integration**: Matcher and HTTP CORS tests green in CI for allow + deny matrices above. 2. **Local manual**: Run API with `CORS_ORIGINS=https://*.cl8y.com` and: ```bash curl -sI -X OPTIONS "http://127.0.0.1:8080/api/v1/signatures/status" \ -H "Origin: https://dex.cl8y.com" \ -H "Access-Control-Request-Method: GET" # expect: access-control-allow-origin: https://dex.cl8y.com curl -sI -X OPTIONS "http://127.0.0.1:8080/api/v1/signatures/status" \ -H "Origin: https://evil.com" \ -H "Access-Control-Request-Method: GET" # expect: no access-control-allow-origin ``` 3. **Production after deploy**: Same OPTIONS/GET probes against `https://api.terms.cl8y.com` with `Origin: https://dex.cl8y.com` show echoed ACAO; loading `https://dex.cl8y.com` no longer shows `Unable to verify terms acceptance: Failed to fetch` for network/CORS reasons (subsequent product errors, if any, must be explicit API error strings). 4. **Redirect still checked separately**: Signing return to DEX still requires portal allowlist entry — verify docs mention this so CORS fix is not mistaken for full end-to-end sign-return readiness. --- ## Out of scope - Implementing portal `VITE_REDIRECT_URI_ALLOWLIST` wildcards - Changing property auto-upsert / admin registration - Broadening `allow_methods` / `allow_headers` policy beyond current `Any` - Shipping `CORS_ORIGINS=*` to production
PlasticDigits commented 2026-08-15 09:52:07 +00:00 (Migrated from gitlab.com)

mentioned in issue #8

mentioned in issue #8
PlasticDigits commented 2026-08-25 07:04:39 +00:00 (Migrated from gitlab.com)

mentioned in issue #12

mentioned in issue #12
PlasticDigits commented 2026-08-26 01:43:08 +00:00 (Migrated from gitlab.com)

mentioned in issue #13

mentioned in issue #13
PlasticDigits commented 2026-08-29 11:16:25 +00:00 (Migrated from gitlab.com)

mentioned in issue #14

mentioned in issue #14
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-ecosystem-legal#7
No description provided.