fix(levelcode): /ai 301'd to itself forever on a staging host - #420
Merged
Merged
Conversation
Reported as an infinite redirect loop: /ai/login on a tunnel host logging identical 301s
to itself until the browser gave up.
CAUSE. LEVELCODE_ORIGIN is ENV-overridable "for staging"; LEVELCODE_HOSTS was a hardcoded
frozen pair. Point the origin at a tunnel without being able to add that tunnel to the
host list and #ui takes the non-LevelCode branch, which bounces any /ai path to
LEVELCODE_ORIGIN — now the same host it is already serving. The browser follows the 301
straight back into the same action. The override was only ever half-usable.
TWO FIXES, because either alone leaves a trap:
1. LEVELCODE_HOSTS now parses a comma list from ENV, so a staging or tunnel host can be
declared a LevelCode host and serve the account app properly — bare-path funnelling
and all.
2. #ui refuses to redirect a host to itself. Even correctly configured this is worth
having: a config mistake should degrade to "serves the app" rather than to a redirect
storm that looks like an application bug.
TWO SPECS THAT PROVED NOTHING, FIXED. Writing these I caught myself twice:
- "parses a comma list the way the constant is built" re-implemented the split/strip
expression and asserted the copy matched itself. Extracted the real parse_hosts and
the spec now calls it.
- "builds the constant from the environment" compared LEVELCODE_HOSTS to
parse_hosts(default) — which is satisfied just as happily by a hardcoded array,
because with no override the two values are identical. The constant is frozen at
class load so no spec can rebuild it from ENV; the assertion is now against the
SOURCE, which is the only thing that distinguishes the two.
Bypass-verified: the self-redirect guard removed (the reported loop); origin_is_self?
forced false; and the hosts hardcoded again — the last one only caught after the spec was
fixed to be capable of catching it.
12 examples, 0 failures.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds environment-configurable LevelCode hosts and prevents self-redirect loops for staging deployments.
Changes:
- Parses
LEVELCODE_HOSTSfrom the environment. - Adds self-redirect protection.
- Expands request specs for host parsing and tunnel behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Review |
|---|---|
app/controllers/static_controller.rb |
Host configuration and redirect protection added. Moderate issue: routing does not apply the same host normalization, causing mixed-case hosts to bypass the bare-path funnel. |
spec/requests/static_spec.rb |
Adds coverage for host overrides, parsing, and redirect-loop prevention. |
Suppressed comments (3)
app/controllers/static_controller.rb:67
- This check compares only hostnames, but
URI#hostdrops the origin's port (and the scheme is ignored too). For example, withLEVELCODE_ORIGIN=https://preview.example:8443, a request tohttp://preview.example:3000/ai/loginis not a self-redirect—the target is a different origin—yet this guard renders the shell on port 3000 instead of redirecting to the configured canonical origin. Compare the full effective origin (scheme, host, and port) before suppressing the redirect.
URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s)
app/controllers/static_controller.rb:69
ENV.fetchaccepts an explicitly emptyLEVELCODE_ORIGIN. For that valueURI.parseyields no host, so this predicate is false and the redirect branch builds/ai/..., a relative URL on the same host; the infinite redirect loop remains. Validate the origin at boot or treat a missing/invalid host as a non-redirecting configuration before callingredirect_to.
URI.parse(LEVELCODE_ORIGIN).host.to_s.casecmp?(request.host.to_s)
rescue URI::InvalidURIError
false
app/controllers/static_controller.rb:12
- This new environment variable does not by itself make a tunnel reachable in production/staging:
config/environments/production.rb:114-129still hardcodesconfig.hostsand does not includeLEVELCODE_HOSTS. Rails HostAuthorization rejects a host such asthinly.ngrok.appbefore routing reaches this controller, so the documented two-variable setup still returns 403 unless a separate host allowlist is configured. Wire the configured hosts intoconfig.hostsor document that additional requirement.
# ENV-overridable so a staging or tunnel host (ngrok, a preview deploy) can serve the account app.
# MUST be set together with LEVELCODE_ORIGIN: overriding the origin alone points the bounce at a host
# that is still not recognised as a LevelCode host, and /ai then 301s to itself forever. The
# self-redirect guard in #ui makes that misconfiguration render instead of loop, but set both.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
… a mixed-case Host cannot split them Review finding on #420 (Copilot), confirmed: the controller downcased request.host before checking LEVELCODE_HOSTS, but the route constraint that keeps the 7-char shortcode lookup off LevelCode hosts compared req.host raw. Rack 3.2 hands req.host through exactly as the client sent it (only the port is stripped), so `Host: LevelCode.AI` was a LevelCode host to #ui and NOT one to routing — and a 7-char bare path such as /pricing resolved as a short code and landed on /link-not-found before #ui ever ran. The decision now lives in one place, StaticController.levelcode_host?(host), which normalises exactly as parse_hosts normalises the list. The instance predicate and the route constraint both call it; neither compares the list on its own any more. Pinned from both sides: a request-level example sends the mixed-case header on a 7-char bare path and expects the /ai funnel (not /link-not-found); unit examples cover the predicate's case folding, the shortener host, "" and nil; and a source-level example asserts routes.rb calls the predicate and no longer touches LEVELCODE_HOSTS directly. Verified: static_spec 17 examples; full suite 1043 examples, 0 failures; rubocop clean. Reverted in turn: only the routes line -> exactly the two routing-dependent examples fail; only the predicate's downcase -> exactly the two case-dependent examples fail.
…he host policy moves Spec-only. Five examples here asserted a bare 200, which cannot tell the shortener shell from the account shell — so a self-redirect guard that rendered the wrong one passed the whole file. Each now names its template. Also adds the inverse of every "funnels a 7-char bare path" example: on thin.ly the same shape of path IS a short code. Without it, a route constraint that withheld the lookup from every host satisfied every example. The two overrides of the host policy go through one helper, with_levelcode_hosts, so the examples do not have to change when the seam underneath them does. Verified against the current, unrefactored code: 18 examples, 0 failures. Then broken four ways, each caught only by the examples it should be: the guard rendering the wrong shell -> 1 (previously 0); a LevelCode host rendering the shortener shell -> 3; the shortener host rendering the account shell -> 2; the lookup withheld from every host -> the new inverse example.
…to Levelcode::Hosts
No behaviour change. The 13 request examples pinned in the previous commit pass against
this one untouched — the only edits to that file are the body of one helper, a comment,
and the five unit examples that moved.
What was wrong with the shape. "Which hosts are LevelCode hosts, and where is the
canonical origin" had accreted inside a controller as two constants and two class methods,
interleaved because one constant needed a method defined above it. config/routes.rb
reached into that controller for its constraint, a mailer comment pointed at it, and
because the constants were frozen at class load the only way to test "the list really
comes from the environment" was a regex over the controller's source. A second
source-text assertion checked which method routes.rb called.
Levelcode::Hosts is that policy as a small immutable value: hosts, origin, #include?(host)
and #origin?(host), built by .from_env(env) and memoised as .current. The controller and
the route constraint both ask .current, so they cannot disagree about a host — which is
the property the review on this PR was about.
The two source-text assertions are gone, replaced by behaviour:
- .from_env takes the environment as an argument, so reading it is directly testable;
- .current is checked on a throwaway subclass, which has its own memo;
- "routing and the controller share one policy" is the tunnel example: its host exists
only in the overridden policy, and /pricing is seven characters.
In #ui the self-redirect guard becomes an `elsif ai_path?` branch instead of two early
returns that each re-tested the path. origin_is_self? no longer re-parses a boot-time
constant on every request. The mailer comment named a constant that no longer exists.
Semantics are carried over exactly, including where the list is trimmed (parse) and where
it is not (lookup). LEVELCODE_HOSTS and LEVELCODE_ORIGIN keep their names and defaults.
Verified: static 13 + hosts 13 examples; full suite 1058 examples, 0 failures; rubocop
clean; brakeman 0 warnings under CI's invocation; zeitwerk:check good. Six mutations, each
caught only by its own examples: no case folding -> 2; routing building its own policy
-> 1; the controller keeping a private list -> 2; origin? blind -> 4; the guard removed
from #ui -> 1; .current hardcoded to the defaults -> 1.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A dev build pointed at an ngrok tunnel produced an endless redirect loop —
/ai/loginlogging identical 301s to itself:Cause
LEVELCODE_ORIGINisENV.fetch-able and commented "ENV-overridable for staging".LEVELCODE_HOSTSwas a hardcoded frozen pair.So you can point the origin at a tunnel, but you cannot add that tunnel to the host list.
#uithen takes the non-LevelCode branch and bounces any/aipath toLEVELCODE_ORIGIN— which is now the host it is already serving. The browser follows the 301 straight back into the same action.The staging override was only ever half-usable.
Two fixes, because either alone leaves a trap
LEVELCODE_HOSTSparses a comma list from ENV, so a tunnel or preview host can be declared a LevelCode host and serve the account app properly — bare-path funnelling included.#uirefuses to redirect a host to itself. Worth having even when configured correctly: a config mistake should degrade to serves the app, not to a redirect storm that reads like an application bug.Where the policy lives
Review found that routing and the controller each decided "is this a LevelCode host?" on their own, and only the controller folded case. Rack passes the
Hostheader through exactly as sent (only the port is stripped), soHost: LevelCode.AIwas a LevelCode host to#uiand not one to the route constraint — a 7-char bare path like/pricingresolved as a short code and landed on/link-not-foundbefore#uiever ran.Rather than have
routes.rbreach into a controller, the policy is now its own small immutable value,Levelcode::Hosts(app/services/levelcode/hosts.rb):#hosts,#origin#include?(host)#origin?(host).from_env(env = ENV)LEVELCODE_HOSTS/LEVELCODE_ORIGIN.currentStaticController#uiand the route constraint both askLevelcode::Hosts.current, so they cannot disagree about a host. The controller no longer carries constants or class methods (94 → 71 lines), and the guard is anelsif ai_path?branch rather than two early returns that each re-tested the path. The env var names and defaults are unchanged.How it is tested
No assertions against source text. An earlier revision of this PR had two — a regex over the controller's source to prove the list came from ENV (the constant was frozen at class load, so nothing else could), and a string match on which method
routes.rbcalled. Both are replaced by behaviour:.from_envtakes the environment as an argument, so reading it is directly testable..currentis checked on a throwaway subclass, which has its own memo and never disturbs the real one./pricingis seven characters.Every request example names the shell it expects. Five of them used to assert a bare 200, which cannot tell the shortener shell from the account shell — a self-redirect guard that rendered the wrong one passed the whole file. There is also now an inverse example: on thin.ly a 7-char path is a short code. Without it, a constraint that withheld the lookup from every host satisfied everything.
spec/requests/static_spec.rb13 examples ·spec/services/levelcode/hosts_spec.rb13 examples.Verification
zeitwerk:checkgood.ca1adb6pins the behaviour against the old code;21d3bedmoves the code, and the 13 request examples pass against it untouched — that file's only edits are one helper's body, a comment, and the unit examples that moved.origin?never matches#ui.currenthardcoded to the defaultsCommits, in reading order
f48b35ffix —LEVELCODE_HOSTSfrom ENV, and the self-redirect guard.fb9a972fix — review: one host predicate for routing and the controller.ca1adb6test — pin which shell each host and path renders.21d3bedrefactor — the policy moves intoLevelcode::Hosts.developis merged in (no overlap with these files).