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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user