From be61b5873ab059c55cc0fb68ad3d9f52c57aa8a1 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sun, 23 Aug 2026 14:38:59 +0100 Subject: [PATCH] Correct the R1 mechanism: the attribute carries the reload, not the writer A reviewer pass found the central claim inverted. The text said the token never travels as a third-party cookie because fetcher.ts writes it inside the frame. It does travel as one: activeJwtToken is a module-level variable filled only from the response header, nothing reads the JWT cookie back, and getCookie is called once in the whole app for XSRF-TOKEN, so the first request after a reload sends no header and the token arrives ambiently. What spares it from blocking is the Partitioned attribute authCookieOptions sets, which is what #2214's control cookie exists to prove. Consequences elsewhere in the section. Saying the upstream Partitioned work buys no flow that does not already work was false: AUTH_SEND_JWT_HEADER ships off, so in the default configuration nothing writes a partitioned cookie at all. The OAuth handoff was described only in the shape that needs that upstream work, when answering the redemption with X-JWT needs nothing upstream. The partition-key description was a counterfactual, since a cookie with no Partitioned attribute has no partition key, and the SameSite clause did not apply to a top-level callback navigation under AUTH_SAME_SITE=none. Also: the documentation gap is two edits, since the parameters page documents the flag but still promises SameSite=Strict and a __Host- prefix that #2197 removed; #2214 adds three TLS functions and only two are table-driven; the task list still claimed R1 has no e2e coverage; the constraint bullet still described setAuthCookie's pre-#2197 behaviour; and the Path B cost line had swapped the real upstream dependency for an invented one. Wrapped to 100 columns. --- .../2026-08-19-frontend-simplification.md | 110 +++++++++++------- 1 file changed, 71 insertions(+), 39 deletions(-) diff --git a/docs/plans/2026-08-19-frontend-simplification.md b/docs/plans/2026-08-19-frontend-simplification.md index 2589b50b..169e21fe 100644 --- a/docs/plans/2026-08-19-frontend-simplification.md +++ b/docs/plans/2026-08-19-frontend-simplification.md @@ -40,41 +40,66 @@ page, so a test that signs in and posts without reloading passes while the persi broken. OAuth sits outside that criterion as the flow is built today, and no frontend change brings it -inside. The provider callback lands in a popup, which is a top-level context of its own, so the -cookie set there is keyed to the auth host and not to `food.com`, and the frame embedded in -`food.com` is a different partition that never sees it. `SameSite` compounds it, being judged -against the top-level site and not against the request's own origin, and the cookie is -`HttpOnly` besides, so the popup cannot read it to hand it over. None of that is a defect; it is the -partitioning model behaving as specified. Bringing OAuth inside the criterion needs the -server-mediated handoff described in the constraints below, where the popup posts a one-time code -back to its opener, the frame redeems it over XHR, and the `Set-Cookie` answering that request is -issued to the embedded context and keyed to `food.com`. That is a backend feature and out of scope -here. Until it exists, an operator who needs OAuth off-domain serves remark42 from the same -registrable domain as the site, or accepts that OAuth readers sign in on the instance's own origin. +inside. The provider callback is a top-level navigation in a popup, so `Service.Set` writes its +cookie there as an ordinary first-party cookie for the auth host, carrying no `Partitioned` and so +having no partition key at all. The frame on `food.com` sees it only as an unpartitioned third-party +cookie, which is exactly what a blocking browser refuses. The cookie is `HttpOnly` besides, so the +popup cannot read it and hand it over in script. `SameSite` is not what obstructs this: the +separate-domain manual has operators set `AUTH_SAME_SITE=none`, and the callback navigation is +top-level regardless. + +Bringing OAuth inside the criterion needs a server-mediated handoff, where the popup posts a +one-time code to its opener and the frame redeems it over XHR. Two shapes exist and they cost +differently. If the redemption answers with `Set-Cookie`, that cookie has to carry `Partitioned` to +be stored at all, which is the upstream library work described below. If it answers with `X-JWT` +instead, `fetcher.ts` writes the partitioned pair itself and nothing upstream has to change, which +makes it the cheaper of the two. Either is a backend feature and out of scope here. Until one +exists, an operator who needs OAuth off-domain serves remark42 from the same registrable domain as +the site, or accepts that OAuth readers sign in on the instance's own origin. **Where that stands.** Master satisfies the criterion today, provided `AUTH_SEND_JWT_HEADER` is on. -The server returns the token in `X-JWT`, `fetcher.ts` writes the `JWT` and `XSRF-TOKEN` cookies -itself, and because that write runs inside the frame the pair is keyed to `food.com` and comes back -on the reload. The token never travels as a third-party cookie, which is why the browser's blocking -never reaches it. The flag is off by default and `site/content/docs/manuals/separate-domain/index.md` -does not mention it, so the arrangement works and nobody is told how; documenting it is the whole -remaining gap for the flows named above. +The server returns the token in `X-JWT` and `fetcher.ts` writes the `JWT` and `XSRF-TOKEN` cookies +itself through `authCookieOptions`, which marks them `SameSite=None; Secure; Partitioned` in a +third-party context. The attribute is what carries the reload, not the fact that the write happened +in the frame: `activeJwtToken` in `fetcher.ts` is a module-level variable filled only from the +response header, nothing ever reads the `JWT` cookie back, and `getCookie` is called once in the +whole app for `XSRF-TOKEN`. So the first request after a reload sends no header and the token +arrives as an ambient cookie, third-party by definition inside the frame, and survives only because +it is partitioned. #2214's control cookie encodes exactly that: an unpartitioned `SameSite=None` +cookie written by the same script in the same frame has to be dropped, or the case declares itself +vacuous. -The e2e suite covers the rendering half through `TestCrossOrigin_WidgetRendersOnAnotherOrigin`, which +Documenting it is the remaining gap for the flows named above, and it is two edits rather than one. +`site/content/docs/manuals/separate-domain/index.md` covers only `ALLOWED_HOSTS` and +`AUTH_SAME_SITE`, so it needs the flag added. `site/content/docs/configuration/parameters/index.md` +does document the flag, but its mitigation list still promises `SameSite=Strict` cookies and a +`__Host-` prefix on https, neither of which `authCookieOptions` does in a third-party context since +#2197, so that page needs correcting. Its warning that the flag increases XSS exposure compared with +server-set `HttpOnly` cookies is the trade-off an operator has to be handed in the same breath as +the recommendation. + +The e2e suite covers the rendering half through `TestCrossOrigin_WidgetRendersOnAnotherOrigin`, +which proves the document loads on another origin and reports itself through postMessage across the boundary, and `ALLOWED_HOSTS` refusal through `TestCrossOrigin_DisallowedHostNeverReportsInited`. The open #2214 adds the authentication half over TLS, the reload included, and runs it once more against a browser configured to block third-party cookies. That second case needs -`IgnoreDefaultArgs`, because Playwright's own `--disable-features` list switches partitioning off and +`IgnoreDefaultArgs`, because Playwright's own `--disable-features` list switches partitioning off +and beats the flags passed through `Args`, which would leave the case asserting nothing. -Both of its TLS cases are table-driven over two flows, so anonymous and email are each measured in a -third-party frame with the reload, and each again under enforced partitioning, every subtest carrying +`TestHTTPS_CrossOriginSignInSurvivesAReload` and `TestHTTPS_SessionSurvivesThirdPartyCookieBlocking` +are table-driven over two flows, so anonymous and email are each measured in a third-party frame +with the reload, and each again under enforced partitioning, the blocking case giving every subtest its own control cookie and its own partitioned-JWT guard in a fresh context so neither can pass -vacuously. Telegram is the one of the three still resting on inference. It is exercised nowhere and +vacuously. `TestHTTPS_AuthCookiesCarryTheThirdPartyForm` is the third and reads the attributes out +of the browser store directly. Telegram is the one of the three still resting on inference. It is +exercised nowhere and cannot be until #2208 makes the Telegram API base URL configurable, because without that the stack -cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let the suite measure it. The -inference itself is that the client-side writer keys off `X-JWT` on any auth response and not off the +cannot answer as Telegram; `go-pkgz/auth` #316 is the change that would let the suite measure it. +The +inference itself is that the client-side writer keys off `X-JWT` on any auth response and not off +the provider, so nothing in it distinguishes one flow from another. The distinction is worth keeping visible, because a criterion resting on flows the suite cannot reach is a milder version of the defect this section was rewritten to remove. @@ -90,9 +115,12 @@ first half. Its persistence never worked on https: the client wrote its copy und prefix that neither the backend nor the widget's own reader ever asks for, and marked it `SameSite=Strict`, which is never sent from a third-party frame. Both are corrected here, and the frontend now marks its cookies `SameSite=None; Secure; Partitioned` when it detects a third-party -context. The server-set cookies are untouched and still carry no `Partitioned`, but with the header -path in place nothing depends on them surviving in a third-party frame, so partitioning them upstream -buys no flow that does not already work. +context. The server-set cookies are untouched and still carry no `Partitioned`. That is what makes +the +upstream work matter rather than what excuses it: `AUTH_SEND_JWT_HEADER` defaults to false, so in +the configuration remark42 actually ships nothing writes a partitioned cookie anywhere, and +partitioning the server's pair is what would carry email, anonymous and Telegram off-domain without +asking operators for a flag that costs them XSS exposure. Constraints on any frontend design: @@ -109,14 +137,17 @@ Constraints on any frontend design: `Service.Set` with only `HttpOnly`, `Path`, `Domain`, `MaxAge`, `Secure` and `SameSite`, and `AUTH_SAME_SITE` in `cmd/server.go` offers `default`/`none`/`lax`/`strict` with no partitioned axis, since `Partitioned` is a separate attribute. The first option therefore starts with a PR to - the library; the second needs nothing upstream and is already shipped, which is why it is the one - R1 leans on + the library. The second needs nothing upstream and already works, which is why R1 leans on it, but + it is gated behind a flag that ships off and costs XSS exposure, so it answers the requirement + without being the answer an operator gets by default - the popup-to-iframe handoff cannot be done in JS. The JWT cookie is `HttpOnly: true`, set in - `Service.Set`, so the top-level popup cannot read it to hand over, and the receiving end would not - work either: `setAuthCookie` in `cookies.ts` writes `SameSite: 'Strict'` under a `__Host-` prefix - with no `Partitioned`, and Strict is never sent from a third-party frame. The handoff has to be - server-mediated, a one-time code redeemed for a `Set-Cookie … Partitioned` issued from the - embedded context. Email and anonymous authenticate over XHR from inside the iframe, which keeps + `Service.Set`, so the top-level popup cannot read it to hand over. The receiving end is no longer + the obstacle it was: since #2197 `authCookieOptions` in `cookies.ts` returns + `SameSite=None; Secure; Partitioned` in a third-party https context and adds no `__Host-` prefix. + What remains missing is any route from the popup's storage bucket to the frame's, so the handoff + has to be server-mediated, a one-time code redeemed either for a `Set-Cookie … Partitioned` or, + more cheaply, for an `X-JWT` the frame's own writer turns into the partitioned pair. Email and + anonymous authenticate over XHR from inside the iframe, which keeps them working while third-party cookies are permitted, but XHR does not create a partition by itself, so they need `Partitioned` for the same reason OAuth does - the failure mode is silent rather than an error, which is why #1139 reads as a hang. OAuth @@ -218,7 +249,8 @@ skipped entirely. That would put a live JWT into `Referer`, into history, and in the route is one that logs bodies. It is unreachable in any case, since the JWT cookie is `HttpOnly: true`, set in `Service.Set`, and no JS can read it to build the URL. -So anonymous-first is what the current configuration gives, and the defensible Path B position is that +So anonymous-first is what the current configuration gives, and the defensible Path B position is +that making the document authenticated is a scoped piece of security work with its own cost. Until that is costed, budget for anonymous-first: render anonymous, then hydrate the user state over XHR, which does carry the header. Anonymous-first is also what a shared cache wants, though see the hydration @@ -366,8 +398,7 @@ change and collapse persistence (`thread_test.go`), anonymous and email auth (`a profile iframe and last-comments (`widgets_test.go`). What remains uncovered is a different list, and it is the contract surface rather than the -behaviour: a cross-origin host page (every host page in the suite is served from the widget origin, -so R1 has no coverage at all), the `comments.html` fallback, `remark_config` fields (`url`, +behaviour: the `comments.html` fallback, `remark_config` fields (`url`, `page_title`, hash deep links, `max_shown_comments`, the three `show_*_subscription` flags, `__colors__`), the listener leak on a repeated `createInstance`, timezone-local date rendering, the unknown-locale fallback, and the composer. @@ -494,8 +525,9 @@ compiled, it is what verifies it. **Cost**: months to an opt-in parallel UI reads as a floor derived from the optimistic architecture, and the optimistic architecture does not hold. Anonymous-first is forced rather than chosen, so the fragment layer reproduces the entire authenticated tree rather than a delta; add the three -subscription flows and, if R1 is to be honoured, a server-side equivalent of the header fallback, -since a server-rendered fragment has no client-side writer to key the cookie to the embedding site. +subscription flows and, if R1 is to be honoured, the upstream `Partitioned` work in `go-pkgz/auth`, +since a server-rendered fragment has no client-side writer and its `Set-Cookie` is stored in a +third-party frame only when it carries the attribute. On one contributor the realistic figure is long enough that the plan's own warning applies to the schedule and not only to the design. Deletes the entire npm toolchain. **Against it**: 2,400 lines of the most stateful code get rewritten; a second HTML-fragment API surface becomes permanent