Repository navigation
feat(levelcode): signing in at levelcode.ai lands in the web editor; the API takes the session cookie only from the account site's own pages - #454
Merged
Conversation
…e account site's own pages The LevelCode API accepts the levelcode.ai session cookie as well as an editor's bearer token, and, as a token-first API, has no CSRF token. SameSite=Lax is a site boundary, not an origin one, so a page on another origin of the same site is sent the cookie too. That is what kept the web editor off a levelcode.ai subdomain (docs/WEB.md in levelcode); app.levelcode.ai is where it is going. user_from_session now counts the cookie only when the request comes from this origin's own pages (Sec-Fetch-Site same-origin) or from the visitor (none). A browser that sends no Sec-Fetch-Site (older than 2023) is judged by Origin: none, or this origin's own. The account site's calls are same-origin, in production and through the dev server's proxy; the editors use bearer tokens. Spec (session_cookie_spec.rb, the real sign-in path, no stubbed authentication): from the editor's origin and from another site the profile, a checkout and the billing portal answer 401 and Stripe is not called — on the previous code the profile answered 200, the checkout got past sign-in to a 400, and the billing portal session was opened; the account site's own pages, a typed address, an old browser and a bearer token from the editor's page still work.
…r when it is on The owner's model is chatgpt.com: signing in at levelcode.ai takes you to the work, not to your account. A web sign-in (no editor signing in) used to end on /ai/account. It now ends on: - the page the visitor asked for, `next` — a plain path under /ai/ (a query is kept; no scheme, host, `//`, backslash, percent-encoded slash, dot segment, blank, control character or fragment; 512 characters at most). POST /ai/auth/verify takes it in the body; GET /ai/auth/oauth/:provider takes it in the query and keeps it in the session across the provider, like `state`, and a sign-in started again without one does not inherit it; - else the web editor, when the web edition is on: its configured address (an origin and a path, the first origin when LEVELCODE_WEB_EDITOR_URL is unset) with `signin=1`, the same address as the account site's "Open LevelCode". The editor takes the tab to /ai/login with its own PKCE challenge, finds the session just made here, and is back signed in with nothing typed; - else /ai/account, as before. With the web edition off (production today) nothing changes. An editor that is signing in (redirect_uri + code_challenge) is unchanged and `next` is not looked at. The editor's "Manage account" hand-off and the Stripe returns still land on /ai/account. Specs (web_spec.rb "where a web sign-in lands"): the editor address, its path kept, its origin when none is configured; off -> /ai/account; `next` honoured editor on or off; eleven malformed `next` values ignored; an editor sign-in untouched; through OAuth: the editor, a `next` carried across the provider, not inherited by a restarted sign-in, a malformed one ignored. Eight fail on the previous controller (the other two pin what must not change). The 14 existing "mints nothing for <a callback that is not the editor's>" cases still mint nothing and never go to that address; with the editor on, the web sign-in they fall back to now lands on the editor's own address instead of /ai/account, and they say so.
…host now EditorCallback's note said the editor (and the extension host's wildcard parent) must not be hosted on a subdomain of levelcode.ai while the API took the session cookie from any request. It takes it only from this origin's own pages now, and the /ai/* POSTs carry Rails' CSRF token, so app.levelcode.ai — the owner's choice — is fine. The note now says so, and names the two things a subdomain can still do that another domain cannot: plant a cookie for .levelcode.ai (a `__Host-` session cookie would refuse it) and run whatever the page runs (LevelCode's own code only; isolate the extension host on another domain before it runs anyone else's).
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Encoded dot segments bypass the required /ai/ destination restriction.
1 open issue
What changed in this PR
Updates LevelCode web sign-in destinations and restricts API session-cookie authentication ahead of hosting the editor on a subdomain.
Changes:
- Limits cookie authentication using request-origin headers.
- Adds
next, web editor, and account-page landing priorities. - Adds request coverage and updates hosting guidance.
| File | Description |
|---|---|
| spec/requests/levelcode/web_spec.rb | Tests sign-in destinations and fallback behavior. |
| spec/requests/api/levelcode/v1/session_cookie_spec.rb | Tests cookie restrictions and bearer-token access. |
| app/services/levelcode/editor_callback.rb | Updates subdomain hosting documentation. |
| app/controllers/levelcode/web_controller.rb | Adds destination selection and next validation. |
| app/controllers/api/levelcode/v1/base_controller.rb | Restricts session-cookie authentication by request origin. |
Review effort: Balanced
… failed sign-in keeps it Found by the account-site half's review of this branch (onetime, src/levelcode/next.ts): - an encoded dot segment got through: /ai/%2e%2e/account is /account to a browser. Dot segments are now refused plain or encoded; - `next` could name the server's own endpoints (/ai/auth/handoff?code=…, /ai/auth/oauth/…, /ai/csrf). They are not pages; refused; - the characters are now RFC 3986's path and query characters with well-formed escapes, as the SPA's, so a non-ASCII or `<` value is refused on both sides alike; - a failed OAuth sign-in (session_expired, oauth_failed) went back to /ai/login without the page asked for, so trying again landed elsewhere. It carries `next` now. The two validators were run over the same 20,000 generated values (the dot, separator, escape, endpoint and character cases combined at random): 1,685 accepted by both, no disagreement. Specs: six more refused values in the malformed table, and the failed-sign-in case; both fail on the previous commit.
This was referenced Oct 18, 2026
…takes names that only look like one Review comment 4305315961 (on 89e4ad3): /ai/%2e%2e/users/edit passed safe_next and a browser resolves it to /users/edit. Fixed in 4068f5c, which folds %2e before checking each segment; this adds the mixed and single forms the comment names (.%2e, %2e., a lone %2e, two encoded steps up) to the refused table, and pins that a name like `a..` (written a.%2e) is still a page. The table fails on 89e4ad3 (it redirected to /ai/%2e%2e/users/edit) and passes here.
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.

What
Signing in at levelcode.ai now lands you in the LevelCode editor in the browser, the way chatgpt.com lands you in the app, instead of on
/ai/account. That only happens while the web edition is on. With it off, which is production today, nothing changes.The editor is going to app.levelcode.ai (the owner's choice, 2026-10-17). A subdomain was ruled out until now because the API accepted the account's session cookie from any page of the site. The first commit closes that.
Commits
fix(levelcode): the API takes the session cookie only from the account site's own pages. It now requiresSec-Fetch-Site: same-origin, ornonefor an address the visitor opened themselves. An older browser that sends noSec-Fetch-Siteis judged byOrigininstead: none, or this origin's own. Before, a page on any*.levelcode.aicould use the visitor's cookie to read their profile, start a checkout, open the billing portal, or spend credits at the gateway. The spec shows the profile answering 200 and the billing portal session being opened on the old code. The editors use bearer tokens and are unaffected. The account site's own calls are same-origin, in production and through the dev proxy.feat(levelcode): where a web sign-in lands.next(a strictly checked path under/ai/);?signin=1, when it is on. That is the same address as "Open LevelCode": the editor bounces through/ai/loginwith its own PKCE challenge, finds the new session, and is signed in with nothing typed;/ai/account.nextis taken byPOST /ai/auth/verifyand byGET /ai/auth/oauth/:provider, which carries it across the provider in the session. An editor that is signing in is unchanged. The editor's "Manage account" hand-off and the Stripe returns still land on/ai/account.docs(levelcode): the hosting rule inEditorCallbacknow allows a subdomain, and names what a subdomain can still do: plant a cookie for.levelcode.ai, and run whatever the page runs.fix(levelcode):nextfollows the account site's rule exactly. Encoded dot segments are refused, so are the server's own/ai/auth/…and/ai/csrf, and only URL characters are allowed. A failed OAuth sign-in keepsnexton the retry. The two validators agree on 20,000 generated values.Verified
bundle exec rspec: 1621 examples, 0 failures on commit 3, then 826 examples, 0 failures in the LevelCode specs on commit 4 (RAILS_ENV=test,DEV_HOST=http://localhost). Of the new examples, the five cookie-attack cases and eight of the ten landing cases fail on the previous code. The remaining ones pin behaviour that must not change.rubocopis clean on the changed files;brakemanreports 0 warnings.Not run
LEVELCODE_WEB_EDITOR_ORIGINS=https://app.levelcode.aiandLEVELCODE_WEB_EDITOR_URL=https://app.levelcode.ai/are set after this is deployed, and the editor is hosted there.For the owner
__Host-session cookie. A page on a subdomain can still set a cookie for.levelcode.ai. Renaming the Devise session cookie to__Host-…would refuse that, but signs everyone out once. Not done here.Companion PRs: systemu-net/onetime#100 (the account site passes
next, and sends an already-signed-in visitor to the editor) and levelcodeai/levelcode#110 (docs/WEB.md).🤖 Generated with Claude Code