Skip to content

fix(security): container/Fly REQUIRE_API_KEY posture + free-tier CORS leak (#13679 PR E) - #13911

Merged
diegosouzapw merged 1 commit into
release/v3.8.51from
fix/13679e-container-posture-cors
Sep 17, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.51from
fix/13679e-container-posture-cors

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Refs #13679

This is PR E of the #13679 insecure-defaults umbrella (16 findings, split A→F). PRs A, C, D, F
already shipped (#13804, #13813, #13812, #13811). This PR covers items #6, #7 only; item #8
(outboundUrlGuardPolicy local-first default) was analyzed as by-design and partially mitigated —
no change made, per the plan-file and the task instructions.

⚠️ base-red inherited: #13866

Not covered here

Root cause

Item #6 — keyless-by-default + public listener posture. The published Dockerfile and
fly.toml shipped with no REQUIRE_API_KEY set, inheriting the npm/CLI local-first default of
"false". Combined with:

  • a bare docker run -p 20128:20128 … diegosouzapw/omniroute (the exact one-liner in
    README/QUICK-START — no --env-file .env), which publishes the port on every interface with no
    loopback protection, and
  • fly.toml's [http_service] (force_https = true, Fly's edge proxy — inherently public, no
    loopback-bind option exists on Fly),

...the anonymous /v1 LLM proxy was both keyless AND world-reachable on both published deployment
paths. docker-compose.yml already closed this for the compose path via loopback-only binding
(#12568) and intentionally keeps following the operator's own .env, so it needed no change.

The openai-to-gemini-sse.ts:289 sub-citation in the original audit is the DNS-rebinding angle
against /v1beta/models/*:streamGenerateContent — but that line is a plain return new Response(...), not where any auth/CORS decision is made. Investigation confirmed the route is
already classified CLIENT_API (src/server/authz/classify.ts) and fronted by clientApiPolicy
through the central src/proxy.ts middleware before the translator ever runs, and its
CORS-header echo was already hardened fail-closed by #12573
(tests/unit/gemini-cors-wildcard-bypass.test.ts). The exposure depended entirely on the global
REQUIRE_API_KEY posture — which this PR now defaults to true for the published container/Fly
path, closing it. No code change was needed in the translator or the /v1beta route itself; see
tests/unit/issue-13679-gemini-sse-requires-api-key.test.ts for the locking confirmation.

Item #7 — /api/free-tier/summary CORS + usage leak. The route ships an unconditional
Access-Control-Allow-Origin: "*" (by design — the community free-tier catalog is meant to be
publicly embeddable) and, until this fix, always included the operator's own local
usedThisMonth/remaining usage numbers regardless of whether the caller was authenticated. Any
reachable origin (including a DNS-rebinding attacker page on a keyless install) could read the
operator's local token-usage stats. Low severity — aggregate usage, not secrets — but still
per-operator data that should not be unconditionally public.

Fix

  • Dockerfile (runner-base stage): added ENV REQUIRE_API_KEY=true, documented with an
    explicit -e REQUIRE_API_KEY=false opt-out for an intentionally keyless deployment.
  • fly.toml: added REQUIRE_API_KEY = "true" to [env].
  • src/app/api/free-tier/summary/route.ts: compute isAuthenticated(req) once and use it to
    gate usedThisMonth/remaining (returned as null when unauthenticated) while the
    intentionally-public catalog fields (perModel, steadyRecurringTokens, …) stay served to
    everyone — scoping the leak down rather than 401-ing the whole route, which would have broken
    the route's own intended public-catalog use case.
  • No code change for the gemini-SSE sub-finding (already covered — see Root cause above); added a
    locking regression test instead.

Regression tests

  • tests/unit/issue-13679-container-posture-require-api-key.test.ts — Dockerfile/fly.toml default
    posture + confirms the npm/CLI local-first default is untouched.
  • tests/unit/issue-13679-free-tier-summary-usage-leak.test.ts — usage-leak fix.
  • tests/unit/issue-13679-gemini-sse-requires-api-key.test.ts — confirmation lock (already green
    pre-fix; documents why no code change was needed there).

RED (Dockerfile/fly.toml, before the fix):

✖ Dockerfile ships REQUIRE_API_KEY=true by default for the published image
  AssertionError: the published Dockerfile must default to REQUIRE_API_KEY=true …
✖ fly.toml ships REQUIRE_API_KEY=true in [env] for the always-public Fly path
  AssertionError: fly.toml's [env] block must set REQUIRE_API_KEY=true …

RED (free-tier summary leak, before the fix):

✖ issue #13679: unauthenticated remote GET must not leak the operator's local usedThisMonth/remaining
  AssertionError: an unauthenticated remote caller must not learn the operator's local token usage (got 0)
  0 !== null

GREEN (after the fix, all three new files):

✔ Dockerfile ships REQUIRE_API_KEY=true by default for the published image
✔ fly.toml ships REQUIRE_API_KEY=true in [env] for the always-public Fly path
✔ the npm/CLI local-first REQUIRE_API_KEY default is untouched (#13679 PR E constraint)
✔ issue #13679: unauthenticated remote GET must not leak the operator's local usedThisMonth/remaining
✔ issue #13679: the gemini-SSE route is classified CLIENT_API (fronted by clientApiPolicy)
✔ issue #13679: an anonymous/DNS-rebinding-style request to the gemini-SSE route is rejected when REQUIRE_API_KEY=true

Existing tests aligned

None weakened. Ran the full existing tests/unit/free-tier-summary-route.test.ts (5 tests) and
tests/unit/free-tier-summary-radar-overlay.test.ts (16 sub-tests) — both still fully green
unmodified: their bare-request/fresh-DB scenario resolves isAuthenticated() to true (loopback +
setup-incomplete bootstrap path), so usedThisMonth/remaining stay present exactly as those
tests already assert. Also ran tests/unit/gemini-cors-wildcard-bypass.test.ts (#12573) and
tests/integration/security-hardening.test.ts — all green, confirming the existing CORS
fail-closed protection this PR relies on for the gemini item.

Also ran every existing Dockerfile-focused test
(dockerfile-dashboard-embed-arg-10273, dockerfile-base-path-arg,
dockerfile-npm-bundled-cve-patch, dockerfile-build-heap-4076,
dockerfile-better-sqlite3-node-gyp-6700, docker-build-memory-budget) — all green.

Gates run

  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> →
    exit 0, no output.
  • npm run typecheck:core → exit 0 (clean).
  • node scripts/check/check-file-size.mjs → no violations on touched files (pre-existing drift on
    chatHelpers.ts, imageGeneration.ts, roundRobinCombo.ts, stream.ts — none touched by this
    PR, base tip already over its own frozen values).
  • node scripts/check/check-complexity.mjs → OK, no new violations.
  • node scripts/check/check-cognitive-complexity.mjs → OK — 1279 violations (baseline 1437).
  • node scripts/check/check-test-discovery.mjs → OK, all 3 new test files discovered.
  • node scripts/check/check-env-doc-sync.mjs → in sync (REQUIRE_API_KEY already documented in
    both .env.example and docs/reference/ENVIRONMENT.md; this PR only changes deployment
    manifests, not the variable contract).
  • Husky pre-commit (lint-staged + docs-sync + any-budget:t11 + tracked-artifacts) → all passed.

…e leak (#13679)

PR E of the #13679 insecure-defaults umbrella (items #6, #7; item #8 analyzed
as by-design, no change). The published Docker image and fly.toml shipped
without REQUIRE_API_KEY set, so a bare `docker run` (README/QUICK-START
one-liners, no --env-file) or a `fly deploy` combined "keyless" with
"world-reachable" for the anonymous /v1 LLM proxy. docker-compose.yml already
mitigates this via loopback-only binding (#12568) and correctly keeps
following the operator's own .env, so it is untouched. The npm/CLI
local-first REQUIRE_API_KEY=false default in featureFlagDefinitions.ts is
also untouched per the owner's decision.

/api/free-tier/summary ships an unconditional Access-Control-Allow-Origin: "*"
and always included the operator's own local usedThisMonth/remaining usage
regardless of auth — a low-severity info leak to any reachable origin. Both
fields are now withheld from unauthenticated callers while the intentionally
public catalog data stays served to everyone.

The gemini-SSE (openai-to-gemini-sse.ts) sub-finding needed no code change:
/v1beta/models/*:streamGenerateContent is already classified CLIENT_API and
fronted by clientApiPolicy through src/proxy.ts before the translator ever
runs, and its CORS-header echo was already hardened fail-closed by #12573.
REQUIRE_API_KEY=true (this PR's container/Fly default) closes the dependency
that finding cited. Added a locking regression test confirming this chain.

Regression tests:
- tests/unit/issue-13679-container-posture-require-api-key.test.ts
- tests/unit/issue-13679-free-tier-summary-usage-leak.test.ts
- tests/unit/issue-13679-gemini-sse-requires-api-key.test.ts (confirmation)

Refs #13679
@diegosouzapw
diegosouzapw merged commit de369fc into release/v3.8.51 Sep 17, 2026
16 of 21 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…e leak (cheaper-inference#13679) (cheaper-inference#13911)

PR E of the cheaper-inference#13679 insecure-defaults umbrella (items cheaper-inference#6, cheaper-inference#7; item cheaper-inference#8 analyzed
as by-design, no change). The published Docker image and fly.toml shipped
without REQUIRE_API_KEY set, so a bare `docker run` (README/QUICK-START
one-liners, no --env-file) or a `fly deploy` combined "keyless" with
"world-reachable" for the anonymous /v1 LLM proxy. docker-compose.yml already
mitigates this via loopback-only binding (cheaper-inference#12568) and correctly keeps
following the operator's own .env, so it is untouched. The npm/CLI
local-first REQUIRE_API_KEY=false default in featureFlagDefinitions.ts is
also untouched per the owner's decision.

/api/free-tier/summary ships an unconditional Access-Control-Allow-Origin: "*"
and always included the operator's own local usedThisMonth/remaining usage
regardless of auth — a low-severity info leak to any reachable origin. Both
fields are now withheld from unauthenticated callers while the intentionally
public catalog data stays served to everyone.

The gemini-SSE (openai-to-gemini-sse.ts) sub-finding needed no code change:
/v1beta/models/*:streamGenerateContent is already classified CLIENT_API and
fronted by clientApiPolicy through src/proxy.ts before the translator ever
runs, and its CORS-header echo was already hardened fail-closed by cheaper-inference#12573.
REQUIRE_API_KEY=true (this PR's container/Fly default) closes the dependency
that finding cited. Added a locking regression test confirming this chain.

Regression tests:
- tests/unit/issue-13679-container-posture-require-api-key.test.ts
- tests/unit/issue-13679-free-tier-summary-usage-leak.test.ts
- tests/unit/issue-13679-gemini-sse-requires-api-key.test.ts (confirmation)

Refs cheaper-inference#13679
@diegosouzapw
diegosouzapw deleted the fix/13679e-container-posture-cors branch October 18, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant