Compare commits

...
Author SHA1 Message Date
Dmitry Verkhoturov 90e9a81783 Drop the last clause narrating an earlier draft
The paragraph correcting the coexistence claim opened by saying an earlier
reading had it wrong, which is the same history narration the commit before it
removed from four other places. The fact stands without it.
2026-08-24 01:52:38 +01:00
Dmitry Verkhoturov 5045d69c20 Repair two sentences the previous fix broke, and finish one it missed
Verifying the last pass found that two of its own corrections introduced fresh
errors. Removing a reference to an earlier draft left the Telegram sentence
circular, comparing a defect to a milder form of itself with no antecedent. And
the OAuth scope sentence attached its relative clause to the wrong thing, so it
read as though blocking-or-partitioning were the recommended recipe; those are
different facts, since the recipe drops AUTH_SAME_SITE=none and the callback
cookie then carries no attribute at all.

One correction had not been applied. The line saying AUTH_SAME_SITE=none adds an
unpartitioned HttpOnly JWT and contributes nothing was true of the engine the
cookie jar was read on and stated of all of them; Safari drops that pair
outright and Firefox delivers it either way, so it is dead weight on all three
for three different reasons.

Smaller: a configuration survives a browser setting instead of reaching it, the
Path B task list is above its cost paragraph and not below, Firefox never holds
a partitioned copy instead of not needing one, and the two remaining shorthand
mentions of XSS exposure now name the token exposure they mean.
2026-08-24 01:50:13 +01:00
Dmitry Verkhoturov d5ede91a9f Correct three claims a second review pass found overstated
The mechanism paragraph said the server pair being refused is what lets the
widget's partitioned pair land. That is false: CHIPS makes the partition key
part of a cookie's identity, so the two are different cookies and coexist, and
#2218 measured all four at once on Chromium. Firefox is the exception because
Total Cookie Protection files the server cookie under the embedder's partition,
which makes the keys collide, and a script may not replace an HttpOnly cookie it
collides with. The section now reports the outcome per engine and stops
asserting a causal link the measurements do not show.

PartitionedCookies was called the better answer for either path with no limits
attached. Two apply. The option is global, so Service.Set puts it on the OAuth
callback too, and a cookie partitioned to the popup's own top-level context is
one the frame cannot see, which regresses OAuth on the permissive browsers
where it works today. And HttpOnly removes bearer-token theft, not authenticated
action during an XSS, since the XSRF value stays readable by design and script
on the widget origin can still make requests the browser attaches the cookie to.

The evidence claim was broader than any single run supports. The criterion asks
for sign-in, a post and a reload; the permanent suite does all three but only on
Chromium's default policy against a stack that is not the recommended recipe,
and the cross-browser campaign covered sign-in and reload on the recommended
recipe without posting. Both halves are now stated.

Also: 70 runnable top-level tests, not 71, which counted TestMain; and three
engines across four browser targets, since Safari is a WebKit browser.
2026-08-24 01:48:12 +01:00
Dmitry Verkhoturov ca7f55d659 Correct the engine claims, and stop narrating the document's own history
A review pass found the measured section had introduced a contradiction while
fixing one. It said Chromium, WebKit and Safari all refuse the server pair
because the default emits no SameSite attribute, then said two paragraphs later
that Safari blocks third-party cookies whatever the attribute says. Only the
refusal was measured on all three; the cause is the attribute on Chromium and
third-party blocking on WebKit and Safari, and the section now says which is
which.

Three more places stated a single engine's behaviour as every engine's: the
claim that the browser drops the server pair, which Firefox does not; the
Firefox session described as riding on an unpartitioned cookie, when Total
Cookie Protection stores it partitioned despite the missing attribute, which is
why it survives; and the flat claim that master satisfies the criterion, which
Firefox's block-all mode is the exception to.

OAuth off-domain is no longer called impossible outright, since an unpartitioned
cookie is still delivered on a permissive browser with AUTH_SAME_SITE=none; it
fails wherever third-party cookies are blocked or partitioned, and under the
recommended recipe everywhere. The Storage Access route is marked as reasoned
from browser policy instead of measured, because nothing in the campaign called
requestStorageAccess, and Firefox is added to the list of implementers.

Also drops four passages narrating earlier drafts of this document, which the
reader never saw, and refreshes counts that had gone stale against 7de51ad2:
143 and 158 source files, twenty-one and not twenty, and one anchor commit
instead of two.
2026-08-24 01:31:54 +01:00
Dmitry Verkhoturov 10c5b158db Record what the browser matrix measured, and what it changed
The R1 mechanism was reasoned from the code and checked on Chromium. Running it
on two genuinely different registrable domains with real certificates, across
Chromium, Firefox, WebKit and Safari 27, shows the arrangement working
everywhere except Firefox's opt-in block-all, and working by two different
mechanisms.

Chromium, WebKit and Safari refuse the server's pair, because AUTH_SAME_SITE
defaults to emitting no SameSite attribute and a cross-site cookie without one
is rejected; that refusal is what lets the widget's partitioned pair land.
Firefox accepts it, and since that cookie is HttpOnly the browser then forbids
the widget's script from replacing it, so Firefox holds no partitioned copy and
rides on an ordinary third-party cookie even with the flag on. Safari blocks
third-party cookies with nothing configured, which makes AUTH_SAME_SITE=none
alone already broken there rather than deprecated.

It also changes what the upstream Partitioned work is worth, which this section
had written off twice. Building go-pkgz/auth with the option and running the
same matrix gives the same persistence with the JWT still HttpOnly: on Safari,
document.cookie inside the frame returns the token under the header flag and
does not under the partitioned build. The header path's XSS cost is avoidable,
not inherent, so both paths are better with the upstream change even though
neither depends on it.
2026-08-24 01:10:04 +01:00
Dmitry Verkhoturov a0680b11ea Bring the plan up to master, and mark two scope calls as the maintainer's
An end-to-end pass against current master found the document stale in places
where it is used as a factual base for a decision, and found two places where
this branch had overstepped.

The two overreaches first. The acceptance criteria narrowed R1 from any
configured provider to three flows, which is a product-scope decision and not
the factual correction it was presented as, especially while the Path B
constraints still say fixing #1139 must not get harder. And the criterion is
met for anonymous and email by measurement but for Telegram by inference, which
sits badly with a standing requirement being a test a proposal passes. Both are
now written as open decisions with the alternatives spelled out.

Two costs were wrong in Path B's favour and against it. The R1 line claimed a
server-rendered fragment has no client-side writer, contradicting Path B's own
task list, which retains the existing auth through htmx:configRequest; fetcher.ts
already turns X-JWT into the partitioned pair, so the upstream Partitioned work
is mandatory only for a server-set default-on solution. And the XSRF caution
rested on GET /deleteme as though it were unprotected: it sits under radmin
behind Auth, AdminOnly and matchSiteID, and the handler needs a signed token
carrying delete_me, so exempting GET removes one gate of three. The caution
stands but now asks for an audit instead of leaning on that example.

Task 2's backlog was sending someone to write tests that exist: the cross-origin
page, comments.html and its injection case, and the config surface are all
covered now. What is left is hash deep links, max_shown_comments, Telegram, the
storage-denied trigger, and a path-prefixed deployment that #2219 makes concrete.

Also refreshes the figures to 7de51ad2, and adds two items master made
necessary: the published locale list names 17 languages against 24 shipped, and
Path B has to keep the explicit height report #2213 added on panel close.
2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 6f7313fcd7 Price the Storage Access route as it really stands: not reachable today
The section sold it as a permission prompt on top of the existing flow. A
browser denies requestStorageAccess outright when the embedded origin has no
recent first-party interaction to grant against, and remark42 never gets one:
the reader interacts on the provider's origin, and the callback returns to a
document whose first statement is window.close() under ?selfClose. So the real
cost is changing the first-party experience, either by having the callback
collect a click before closing or by establishing interaction some other way,
which is a different order of cost from the other two routes.

Also states the grant correctly. It lets the frame's requests carry the cookie;
it does not make it script-readable, and the JWT cookie is HttpOnly regardless,
which is what the OAuth paragraph above already says.
2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 5224c5dfcf Drop three more banned constructions from the R1 text 2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 3b7a7778dc Price the Storage Access API, and stop calling the header path cookie-free
Two more from the review. The section declared that no frontend change could
bring OAuth inside the criterion, which writes off the Storage Access API: an
embedded frame can ask the browser, on a user gesture, for access to its own
unpartitioned first-party cookies, and both Safari and Chrome implement it.
That would read the cookie the callback already set with no backend work, at
the cost of a revocable permission prompt. It belongs in the comparison, and it
is the only one of the three routes that is not a backend feature.

And the constraint bullet still offered CHIPS or a token not relying on ambient
cookies as alternatives, when the header path relies on one as soon as the page
reloads. Both routes rest on the same attribute; they differ over who writes the
cookie and what that costs.
2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov be61b5873a 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-24 01:09:05 +01:00
Dmitry Verkhoturov 6831206574 Drop two banned constructions from the R1 text and one from Path B 2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 8e16a7373c Record that email is now measured, leaving Telegram as the inference
#2214 turned its two TLS cases into tables over anonymous and email, so both
are exercised in a third-party frame with the reload and again under enforced
partitioning. Telegram is the only one of the three still resting on the
writer keying off X-JWT and not off the provider, with #2208 as the reason it
cannot be measured and go-pkgz/auth#316 as what would change that.
2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 864cde68c8 Narrow the R1 coverage claim to the flow the suite measures
#2214's two TLS cases both sign in anonymously, so anonymous is the only one
of the three named flows exercised in a third-party frame. Email is covered
over http and never embedded, and Telegram is covered nowhere and cannot be
until #2208 makes the API base URL configurable. The expectation that all
three behave alike rests on the client-side writer keying off X-JWT and not
off the provider, which is an inference and now reads as one.
2026-08-24 01:09:05 +01:00
Dmitry Verkhoturov 33e93a427d Correct R1 in the frontend plan: scope it to the flows that can meet it
The acceptance criteria promised sign-in with any configured provider under
third-party cookie blocking, which OAuth as built cannot satisfy: the callback
runs in a popup, a top-level context of its own, so the cookie it sets is keyed
to the auth host and the frame embedded on the other domain is a different
partition that never sees it. The criteria now name email, Telegram and
anonymous, and OAuth carries its own paragraph explaining why it is out and what
would bring it in, which is the server-mediated one-time code the constraints
below already describe.

Two claims went stale alongside it. Master meets the criterion today with
AUTH_SEND_JWT_HEADER on, because fetcher.ts writes the cookie from inside the
frame and the token never travels as a third-party cookie, so the remaining gap
for those flows is documentation and not code. The server-set cookies still lack
Partitioned, but nothing depends on them surviving in a third-party frame any
more, so the upstream work they were waiting on buys no flow that does not
already work.
2026-08-24 01:09:05 +01:00
Dmitry VerkhoturovandGitHub 3286f028e3 Document what each browser actually does with cross-domain auth (#2222)
Measured on real domains over real certificates, Remark42 on one registrable
domain and the host page on another, with a control cookie behind every
blocked column so a run that blocks nothing cannot report a pass.

Three results the manual did not carry. Safari blocks third-party cookies out
of the box, so AUTH_SAME_SITE=none on its own has already stopped working
there, which makes the old recipe broken today and not deprecated later.
Firefox reaches a working session by a weaker route than Chrome and Safari do:
it accepts the server's attribute-less cookie, and because that cookie is
HttpOnly the browser then forbids the widget's script from replacing it, so the
session rides on an ordinary unpartitioned third-party cookie even with the
header flag on. And Firefox's block-all setting discards partitioned cookies
too, so no configuration survives it.

Two parameter descriptions were wrong in ways that matter here. AUTH_SAME_SITE
default emits no SameSite attribute rather than Lax, which is precisely what
lets the widget's own cookie land on Chrome and Safari. And AUTH_TTL_COOKIE
does not govern the cookie that carries the session under the header flag,
since the frontend hardcodes 200h to mirror the default.
2026-08-23 18:03:49 -05:00
UmputunandGitHub c947a06d48 Release the response before tearing the test server down (#2212)
TestRest_securityHeaders and TestRest_frameAncestors both start a server, read
one response, and then call teardown() partway through the test to start a
second server with different options. The first response body is only closed by
a defer, which does not run until the test returns.

httptest.Server.Close waits on connections still in use, so it blocks on a body
that will not be closed until after it returns. The tests deadlock and the whole
rest/api package dies on the timeout rather than on an assertion.

CI pins go 1.25, where the responses are small enough that the connection goes
back to the pool on its own and nothing hangs. On go 1.27 both tests hang, which
is how this surfaced.

Close the body and the client's idle connections before teardown() in both.
2026-08-23 17:51:52 -05:00
UmputunandGitHub 5f439cf1d5 Qualify what works off-domain, and fix two typos beside it (#2221)
The opening summary said Telegram, Email and anonymous auth "would work
everywhere". That holds only with AUTH_SEND_JWT_HEADER set, and the widget's
own cookie is Secure, so the path is HTTPS-only and bounded by ALLOWED_HOSTS
besides. The sentence now states those conditions. What happens without the
flag is two separate things, whether sign-in succeeds in the frame and whether
it survives a reload, and the body below already separates them.

The other two are older: a stray backtick after "work on any domain", and
"expect" for "except" in a bullet whose neighbour already says except.

Related to #2218
2026-08-23 17:51:47 -05:00
Dmitry VerkhoturovandGitHub 7de51ad2ef Document what actually keeps a cross-domain reader signed in (#2218)
* Document what actually keeps a cross-domain reader signed in

The separate-domain manual tells operators to set ALLOWED_HOSTS and
AUTH_SAME_SITE and says authorisation then works anywhere. That stopped being
true as browsers began blocking third-party cookies: the server-set auth
cookies carry no Partitioned attribute, so a browser enforcing the block drops
them whatever their SameSite value. What survives is AUTH_SEND_JWT_HEADER,
where the token returns in a header and the widget writes its own partitioned
cookie from inside the frame, and the manual never mentioned it. It now does,
with the XSS trade-off and a pointer to the parameter page, and it says plainly
that this rescues Email, Telegram and anonymous but not oAuth.

The parameter page's own mitigation list was left wrong by #2197. It promised
SameSite=Strict cookies and a __Host- prefix on HTTPS; authCookieOptions drops
the prefix entirely and uses SameSite=None; Secure; Partitioned whenever the
widget is embedded on another domain, which is the case the flag exists for.

* Say that the JWT header is sent in addition to the cookies, not instead

Both the flag's own help and the parameter table said the header replaces the
server-set cookie. Service.Set does neither: it writes the header and then
falls through to set both cookies, with a comment saying the cookies are needed
because headers do not survive the OAuth redirect. An operator reading either
description would expect the server to stop setting cookies once the flag is
on, and would misjudge what the flag changes about their exposure.

* Correct three details in the cross-domain documentation

The link to the parameter page used Zola's @/ syntax, which Hugo emits
literally as a relative href since there is no render-link hook. It was the
only such link under site/content; the other manuals use the relative form and
this now does too.

The CHIPS description claimed Partitioned makes the cookie unreadable from any
other page the browser visits. The partition key is the top-level site, so a
different site gets a separate cookie while pages and subdomains under the same
site share it. Overstating isolation on the page an operator reads to weigh
risk is the wrong direction to be wrong in.

And Chrome does not block third-party cookies by default: Google's April 2025
position keeps ordinary Chrome on user choice and names Incognito as the mode
that blocks. Naming Safari, Chrome Incognito and browsers configured to block
them says the same thing and stays true.

* Drop AUTH_SAME_SITE from the recommended cross-domain recipe

Measured rather than reasoned, because it reverses guidance this page has
carried for years. With only the remark42-https service taken back to the
default, both reload cases pass for anonymous and email, under a permissive
browser and under one enforcing partitioning.

The cookie jar after an anonymous sign-in says why. With the setting there are
four cookies: the server's unpartitioned JWT and XSRF-TOKEN, and the widget's
own partitioned pair. Without it there are two, the widget's pair alone, and
the session behaves identically. So the setting is doing something real, which
is what makes the passing run meaningful, and what it does is add an
unpartitioned HttpOnly JWT delivered as a third-party cookie to every listed
domain wherever the browser still permits that. Nothing needs it.

It stays documented for the configuration that does need it, which is one
without AUTH_SEND_JWT_HEADER, where the server's cookies are the only ones
there are.

One prediction the experiment falsified: the attribute case was expected to
fail on the default server-set pair. It passes, because a cross-site Set-Cookie
lacking SameSite=None is refused outright, so that pair is absent from the jar
instead of present with the wrong attribute. The manual now says so.
2026-08-23 15:49:06 -05:00
Dmitry VerkhoturovandGitHub 389189afcf Give each import request in TestMigrator_ImportDouble its own reader (#2220)
The test passed one strings.Reader as the body of both POSTs. client.Do
returns once the response headers arrive, and the import answers 202 before
the transport has finished copying the body, so the second http.NewRequest
reads the reader's Len to set ContentLength while the first request's
writeLoop is still advancing it. The race detector caught it on CI as a write
in strings.(*Reader).WriteTo against a read in NewRequestWithContext, failing
a test nothing had touched.

Reproduced in isolation to confirm the mechanism rather than infer it from the
trace: a handler that answers 202 without draining an 8 MiB body, two requests
sharing one reader, and -race reports strings.(*Reader).Len in
NewRequestWithContext against strings.(*Reader).Read on every run. It does not
reproduce in this package locally, which is why it reads as a flake.

Both requests now build their own reader over the same content. The second one
carries a full body where before it inherited a consumed one, which is closer
to what the case is about: a second import arriving while the first is running
still has to be refused.
2026-08-23 14:58:31 -05:00
Dmitry VerkhoturovandGitHub 6f40926241 Drop the origin-anchored public path from the delete-me bundle (#2219)
deleteme.ts set __webpack_public_path__ to window.location.origin plus /web/,
which discards any path prefix the instance is served under. It is inert today
because that bundle references no asset and loads no chunk, so the value is
assigned and never read, but it is wrong by construction and would resolve at
the domain root the moment anyone adds an image to that page. Removing it
leaves webpack's own publicPath: 'auto', which derives the base from the
script's URL and is right in both arrangements.
2026-08-23 14:58:27 -05:00
Dmitry VerkhoturovandGitHub 2640aaee9e Reach what http cannot: the widget over TLS, embedded cross-origin (#2214)
Every service in the suite spoke http, and the browser gates a whole class
of behaviour on the page protocol: Secure cookies, SameSite=None,
Partitioned, and any code reading location.protocol. None of it was
executed, which is how setAuthCookie came to decorate its cookies with
__Host- on https pages and survive for years.

A TLS pair joins the stack: remark42 with SSL_TYPE=static on 8443, and an
nginx serving a host page on its own name on 8444, both on a self-signed
certificate that e2e/tls/generate.sh makes and .gitignore keeps out. Every
context accepts it, and so does the readiness client, since those are the
only servers either talks to. The instance also runs with
AUTH_SEND_JWT_HEADER, which is what makes the widget write cookies of its
own: without it the client-side writer never runs on any https page here
and every assertion about the attributes it chooses is vacuous.

Three cases. Signing in across origins and then reloading, which is the one
the http cross-origin case cannot make: the widget holds its token in
memory for the life of a page, so signing in and posting says nothing about
persistence and only the reload asks whether the cookie was delivered,
stored under a name the backend reads and sent back from a third-party
frame. The cookies themselves, read out of the browser store while the
widget is embedded elsewhere, since a cookie the browser refused is absent
from that list entirely and one it kept but will not send is worse than
useless: every copy of both names has to be Secure and SameSite=None, at
least one has to be partitioned, and none may carry a __Host- prefix
nothing on either side reads. And the same reload under a browser that
blocks third-party cookies, which the widget's own partitioned pair is the
only reason to survive.

That last one needs a browser playwright does not offer: its default
arguments disable ThirdPartyStoragePartitioning outright, so a run
configured wrongly keeps every third-party cookie and the case would pass
while asserting nothing. IgnoreDefaultArgs drops that list and re-supplies
it without the one feature, and a control cookie set from inside the frame
has to be refused before anything else is read, so a playwright release
that changes the list fails as itself instead of going quietly vacuous.

All three pass against master. What TLS still cannot reach, the OAuth popup
above all, is written down in the README.
2026-08-23 14:58:23 -05:00
19 changed files with 865 additions and 92 deletions
+1
View File
@@ -94,6 +94,7 @@ jobs:
# has to carry the same value `make e2e-up` and the suite itself would give it
- name: Build & start the stack
run: |
./e2e/tls/generate.sh
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 E2E_STAMP=$(./e2e/stamp.sh) \
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
+3
View File
@@ -32,3 +32,6 @@ http-client.env.json
# traces from failed e2e runs
/e2e/traces/
# self-signed certificate for the e2e https services, made by e2e/tls/generate.sh
/e2e/tls/*.pem
+1
View File
@@ -42,6 +42,7 @@ rundev:
# stamped the same way the suite stamps a stack it starts itself, so one brought up here is
# accepted instead of rejected as belonging to another checkout
e2e-up:
./e2e/tls/generate.sh
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
e2e-down:
+1 -1
View File
@@ -102,7 +102,7 @@ type ServerCommand struct {
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie (note: increases vulnerability to XSS attacks)"`
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; server-set cookies are still sent (note: increases vulnerability to XSS attacks)"`
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
+4 -1
View File
@@ -271,11 +271,14 @@ func TestRest_securityHeaders(t *testing.T) {
client := http.Client{}
resp, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
// until the test ends, so the body has to be released before the server is torn down here
require.NoError(t, resp.Body.Close())
client.CloseIdleConnections()
teardown()
// check CSP with proxy enabled
+7 -3
View File
@@ -280,10 +280,14 @@ func TestMigrator_ImportDouble(t *testing.T) {
for i := range 50 {
recs = append(recs, fmt.Sprintf(tmpl, i))
}
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
// each request needs its own reader. client.Do returns once the response headers are in, which
// for an accepted import is before the transport's writeLoop has finished copying the body, so
// handing the same strings.Reader to the second NewRequest races that copy: NewRequest reads
// Len() to set ContentLength while WriteTo is still advancing it
body := `{"version":1}` + strings.Join(recs, "\n")
client := &http.Client{Timeout: waitTimeout}
defer client.CloseIdleConnections()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.NoError(t, err)
@@ -294,7 +298,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
client = &http.Client{Timeout: 5 * time.Second}
defer client.CloseIdleConnections()
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
require.NoError(t, err)
req.SetBasicAuth("admin", "password")
assert.NoError(t, err)
+4 -1
View File
@@ -573,9 +573,12 @@ func TestRest_frameAncestors(t *testing.T) {
client := http.Client{}
resp, err := client.Get(ts.URL + "/web/index.html")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
// until the test ends, so the body has to be released before the server is torn down here
require.NoError(t, resp.Body.Close())
client.CloseIdleConnections()
teardown()
// test case without frame-ancestors
+66
View File
@@ -245,6 +245,71 @@ services:
timeout: 3s
retries: 30
# the same widget over TLS, which is the only way to reach anything the browser gates on the
# page protocol: Secure cookies, SameSite=None, Partitioned, and any code reading
# location.protocol. The certificate is self-signed and the suite passes IgnoreHTTPSErrors
remark42-https:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-https"
pull_policy: never
depends_on:
remark42:
condition: service_started
mailpit:
condition: service_started
ports:
- "127.0.0.1:8443:8443"
environment:
- REMARK_URL=https://remark42-https:8443
- SECRET=12345
- AUTH_ANON=true
- SSL_TYPE=static
- SSL_PORT=8443
- SSL_CERT=/srv/tls/cert.pem
- SSL_KEY=/srv/tls/key.pem
# the widget is embedded from another origin here, so its cookies have to survive a
# third-party frame; this is the setting that decides whether they do
- AUTH_SAME_SITE=none
# and the token arrives in a header, so the widget writes the cookies itself through
# setAuthCookie. Without this the client-side writer never runs on any https page in the
# stack, and every assertion about the attributes it chooses is vacuous
- AUTH_SEND_JWT_HEADER=true
# email as well as anonymous, so the flow most operators run off-domain is measured here
# and not inferred from the anonymous one. the writer keys off the X-JWT header rather than
# off the provider, which is exactly the assumption a case signing in by email checks
- AUTH_EMAIL_ENABLE=true
- AUTH_EMAIL_FROM=remark42@example.com
- SMTP_HOST=mailpit
- SMTP_PORT=1025
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-https-var:/srv/var
- ./e2e/tls:/srv/tls:ro
healthcheck:
test: ["CMD", "curl", "--fail", "--insecure", "https://localhost:8443/ping"]
interval: 2s
timeout: 3s
retries: 30
# and a host page for it, on its own name, so the embed is genuinely cross-site over TLS
host-site-https:
image: nginx:1.29-alpine
container_name: "remark42-e2e-hostsite-https"
ports:
- "127.0.0.1:8444:443"
volumes:
- ./e2e/hostsite:/usr/share/nginx/html:ro
- ./e2e/tls:/etc/nginx/tls:ro
- ./e2e/tls/nginx-tls.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
# 127.0.0.1 and not localhost: nginx listens on IPv4 only and busybox wget tries ::1 first
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/post-https.html"]
interval: 2s
timeout: 3s
retries: 30
# catches the email-auth verification message; the suite reads it back over the HTTP API
mailpit:
image: axllent/mailpit:v1.30.7
@@ -268,3 +333,4 @@ volumes:
remark42-e2e-jwtheader-var:
remark42-e2e-noauth-var:
remark42-e2e-anonvote-var:
remark42-e2e-https-var:
+254 -57
View File
@@ -1,9 +1,9 @@
# Frontend direction: two viable paths, and what has to be true for either
Written 2026-08-19, revised 2026-08-22. Every file reference below was re-checked against master
`a82dc8d3` plus #2196, #2197 and #2198, which are treated here as landed: they change the e2e net,
the manifest layout, the fallback page, the asset path and the instance URL, and costing either
direction against the state before them would be costing a world that no longer exists.
Written 2026-08-19, revised 2026-08-24. Every file reference below was re-checked against master
`7de51ad2`. The frontend work merged since 2026-08-22 is treated here as landed: it changes the e2e
net, the manifest layout, the fallback page, the asset path and the instance URL, and costing either
direction against the state before it would be costing a world that no longer exists.
## Overview
@@ -33,23 +33,170 @@ users report against most. The target arrangement is remark42 serving from its o
documented in `site/content/docs/manuals/separate-domain/index.md`, so operators follow it and then
find that authentication behaves differently from the same-domain case.
**Acceptance criteria.** A reader on `food.com` can sign in with any configured provider, post,
reload the page, and still be signed in, on a browser that blocks unpartitioned third-party cookies.
Nothing short of the reload proves it: the widget holds a token in memory for the life of a page, so
a test that signs in and posts without reloading passes while the persistence is entirely broken.
**Acceptance criteria.** A reader on `food.com` signs in through email, Telegram or anonymous,
posts, reloads the page, and is still signed in, on a browser that blocks unpartitioned third-party
cookies. Nothing short of the reload proves it: the widget holds a token in memory for the life of a
page, so a test that signs in and posts without reloading passes while the persistence is entirely
broken.
**Where that stands.** 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 authentication half is not covered and
cannot be until the e2e stack speaks https, because an embedded cookie needs `SameSite=None`, which
browsers accept only with `Secure`.
**Two things about this criterion are decisions, not findings, and they belong to the
maintainer.** They are marked so neither is settled by implication.
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets auth cookies be set
from any embedding domain. OAuth is what visibly fails off-domain (discussion #1139). Telegram,
email and anonymous work where third-party cookies are still permitted, and stop working where they
are not: the server's cookies carry no `Partitioned`, and the header fallback is off by default, so
nothing saves them once the browser blocks unpartitioned third-party cookies.
The first is that it excludes OAuth, where the requirement as originally written said any configured
provider. That is a narrowing of product scope, not a correction of fact. OAuth off-domain fails for
the flow as built on any browser that blocks or partitions third-party cookies, which is Safari's
default; under the recommended recipe, which drops `AUTH_SAME_SITE=none`, it fails on the remaining
browsers too. The routes priced below would change that, and the narrowing sits awkwardly beside
`fixing #1139 must not get harder` in the Path B constraints, which is the same subject. Either the
requirement excludes OAuth and #1139 is a separate goal carrying its own timeline, or the
requirement keeps OAuth and is not met today. This document assumes the first and is not entitled to
assume it.
The second is what counts as evidence, and the criterion is not met end to end by any single run.
The criterion asks for sign-in, a post and a reload. The permanent suite covers sign-in, post and
reload for anonymous and email, but on Chromium's default policy and against a stack running
`AUTH_SAME_SITE=none` alongside the header, which is not the recommended recipe; the blocking case
adds the control and the reload but does not post. The cross-browser campaign covered sign-in,
reload and the controls on the recommended recipe, and did not post. So persistence is measured and
posting-after-reload is not, on the same run. Taking those together, the criterion is met for
anonymous and email by measurement,
and for Telegram by inference from the client writer keying off `X-JWT` and not off the provider. A
standing requirement is meant to be a test a proposal passes, so either inference is acceptable
evidence here and the section should say so, or Telegram leaves the criterion partly pending until
#2208 and `go-pkgz/auth` #316 make it measurable.
OAuth sits outside that criterion as the flow is built today. 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.
Two routes could bring OAuth inside the criterion, and both need costing before either is called
mandatory.
The first is the Storage Access API, which exists for exactly this shape: an embedded frame asks
the browser, on a user gesture, for permission to send its own unpartitioned first-party cookies,
and Safari, Chrome and Firefox all implement it. Note what the grant is and is not. It lets the
frame's
requests carry that cookie; it does not make the cookie script-readable, and the JWT cookie is
`HttpOnly` in any case, which is the same point the OAuth paragraph above makes.
It is also not available to remark42 as the flow stands, which is the part worth pricing. That much
is read off documented browser policy and the code, not measured: nothing in the campaign called
`requestStorageAccess`. A browser
denies the request outright when the embedded origin has no recent first-party interaction to
grant against, and remark42 never acquires one: the reader interacts on the provider's origin, and
the callback returns to the remark42 origin at a document whose first statement is `window.close()`
under `?selfClose` in `iframe.ejs`. Nothing happens there that a browser counts as interaction.
So the cost is not a permission prompt bolted onto the existing flow. Either the callback stops
closing itself and collects a click first, or something else establishes first-party interaction on
the instance's own origin before the frame ever asks. That is a change to the first-party
experience, which is a different order of cost from either handoff shape and has to be weighed as
one.
The second is 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 there 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. Both handoff shapes are backend features and out of scope here; the
Storage Access
route is not, and is the one worth pricing first for that reason. Until something lands, 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 on every browser measured except one,
provided `AUTH_SEND_JWT_HEADER` is on. The exception is Firefox with "block all third-party cookies"
chosen, which discards partitioned cookies too and which no configuration survives.
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.
**Measured across three engines and four browser targets, and the mechanism is not uniform.** The
above was reasoned from the code and verified on Chromium. Driving the real thing on two genuinely
different registrable domains with real certificates, across Chromium, Firefox, WebKit and Safari
27, shows the recommended arrangement working everywhere except one case, and working for two
different reasons.
On Chromium, WebKit and Safari the server's own pair does not reach the frame, and the widget's
partitioned pair is what the session runs on. The reason the server pair is absent differs by
engine: on Chromium `AUTH_SAME_SITE` defaults to emitting no `SameSite` at all and Chromium treats
a missing attribute as `Lax`, so the cookie is not sent cross-site; on WebKit and Safari it is
third-party blocking, which applies whatever the attribute says. Only the outcome was measured; the
causes are read off documented browser behaviour.
The two pairs are not in competition. CHIPS makes the partition key part of a cookie's identity, so
a partitioned cookie and an unpartitioned one of the same name are different cookies and coexist:
#2218 measured all four at once on Chromium with both settings on. Firefox is the exception
precisely because Total Cookie Protection files the server's cookie under the embedder's partition,
which makes the keys match, and a script may not replace an `HttpOnly` cookie whose key it collides
with.
On Firefox the server's pair is accepted, and since the `JWT` it sets is `HttpOnly`, the browser
then forbids the widget's script from replacing a cookie of that name. So Firefox never holds a
partitioned copy of its own, and the session rides on the server's cookie instead. Under Firefox's
default, Total Cookie Protection, that cookie carries no `Partitioned` attribute but the browser
stores it in a per-site partition anyway, which is why it survives. Its opt-in "block all
third-party cookies" discards partitioned cookies as well, so nothing survives it and nothing in
remark42 can change that.
Safari is the sharp end: it blocks third-party cookies with nothing configured, so
`AUTH_SAME_SITE=none`
on its own has already stopped working there for every reader. The old recipe is not deprecated, it
is broken, which is the strongest argument for treating R1 as live work and not a documented
limitation.
**That changes what the upstream `Partitioned` work is worth.** Building `go-pkgz/auth` with a
`PartitionedCookies` option and running the same matrix gives the same persistence with the server's
`JWT` still `HttpOnly`: on Safari, `document.cookie` inside the frame returns the token under the
header flag and does not under the partitioned build, while both keep the reader signed in. So the
two routes are not equivalent, and the header path's token exposure is avoidable, not inherent.
`go-pkgz/auth` #318 carries that change with the measurements behind it.
The documentation gap this section named is closed by #2218. The separate-domain manual now
recommends `AUTH_SEND_JWT_HEADER=true` and carries the XSS trade-off in the same breath, and the
parameter page no longer promises `SameSite=Strict` cookies and a `__Host-` prefix that
`authCookieOptions` stopped emitting in a third-party context at #2197. That PR also measured
`AUTH_SAME_SITE=none` out of the recommended recipe: on Chromium, where that jar was read, the
header flag leaves it adding an unpartitioned `HttpOnly` JWT to third-party delivery and
contributing nothing to persistence. Safari drops that pair outright and Firefox delivers it either
way, so the setting is dead weight on all three for different reasons.
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`.
#2214 added 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 beats the flags passed through `Args`, which would leave the case asserting nothing.
`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. `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 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
asserts more than the evidence holds, which is the defect this section exists to avoid.
`ALLOWED_HOSTS` sets the CSP `frame-ancestors` and `AUTH_SAME_SITE=none` lets the server's auth
cookies be set from any embedding domain. Those server-set cookies carry no `Partitioned`, so they
are the ones Chromium, WebKit and Safari drop; Firefox keeps them, which is why it never holds a
partitioned copy of its own. What survives the drop is the header fallback above.
There is a second mechanism aimed squarely at this, `AUTH_SEND_JWT_HEADER`, which returns the token
in a response header so the client can present it without relying on an ambient cookie. #1877 was
@@ -58,8 +205,11 @@ 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 still need the same treatment, and that is upstream work in
`go-pkgz/auth`.
context. The server-set cookies are untouched and still carry no `Partitioned`. That is what makes
the upstream work matter, not 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 exposes the token to script.
Constraints on any frontend design:
@@ -70,19 +220,28 @@ Constraints on any frontend design:
outright and honours only cookies explicitly marked `Partitioned`; its CHIPS support has been
switched on, off and on again across releases, so pin the current state before relying on a
version number. A design depending on `SameSite=None` surviving has an expiry date
- the endpoint is CHIPS (`SameSite=None; Secure; Partitioned`) or a token not relying on ambient
cookies. Neither is a flag flip, and the first cost is upstream rather than here:
- the endpoint is CHIPS (`SameSite=None; Secure; Partitioned`) either way, and the only question is
who writes the cookie. The header path is not a token free of ambient cookies: it holds the token
in memory for one page, and after a reload the browser sends the partitioned cookie the frontend
wrote. Both routes stand on the same attribute and differ only in whether the server or the
frontend sets it, plus the token-theft exposure the frontend writer carries.
Neither is a flag flip, and the first cost falls upstream, not here:
`go-pkgz/auth/v2@v2.2.0` cannot emit `Partitioned` at all. Both cookies are hand-built in
`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. That is a PR to the library before anything in
this repo changes
axis, since `Partitioned` is a separate attribute. The first option therefore starts with a PR to
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 exposes the token to script, 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
@@ -171,11 +330,17 @@ exposes on `Opts` and threads into the JWT service. remark42 leaves it unset, so
list applies and the short circuit in `Service.Get` never fires for any method. Setting it would
make an authenticated document render possible.
Do not reach for it globally, though. `GET /deleteme` deletes every comment a user has written, and
is a GET deliberately, so that the link in the confirmation email works when clicked. Exempting GET
from XSRF wholesale removes that protection from a destructive endpoint. Anything built on this has
to scope the exemption to the document route alone, and that route has to be provably side-effect
free. Cost that work rather than assuming either that the door is shut or that it is open.
Do not reach for it globally, though, and note that the obvious example does not carry the argument.
`GET /deleteme` deletes every comment a user has written and is a GET deliberately, so that the
link in the confirmation email works when clicked, but XSRF is only one of three gates on it: the
route sits under `radmin`, which applies `Auth`, `AdminOnly` and `matchSiteID`, and
`deleteMeRequestCtrl` then requires a separately signed token carrying a `delete_me` attribute it
cannot forge. Exempting GET wholesale removes one defence there, not the only one.
The caution still stands, but it has to be earned by an audit instead of by that example: scope any
exemption to the document route, establish that route is side-effect free, and check every other
authenticated GET before deciding how much route-scoped work the library needs. Cost that audit,
without assuming either that the door is shut or that it is open.
There is also a query-parameter path, and it is worse than the constraint: `Service.Get` accepts the
token from a query parameter, `?jwt=` rather than the library default `?token=` because remark42
@@ -184,15 +349,15 @@ 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 honest 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
item in Path B for how much that is worth.
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 item in Path B for how much that is worth.
## Verified facts
Checked against the code at `a82dc8d3` and by independent reviewers.
Checked against the code at `7de51ad2` and by independent reviewers.
- 9 runtime dependencies against 61 devDependencies, and **68** override entries, all in the single
`frontend/apps/remark42/package.json` since #2197 removed the workspace root. Before this effort
@@ -212,11 +377,11 @@ Checked against the code at `a82dc8d3` and by independent reviewers.
- The e2e suite is Go and playwright-go since #2180, and passed 60 tests while this was being
written, up from 7. Treat the exact figure as stale on sight; it is the only coverage that
survives a rewrite
- The unit suite is 46 files, 25 `*.test.*` plus 21 `*.spec.*`, and **426 cases**, as jest
enumerates them. Counting only `*.test.*` understates it by twenty files, which is the trap
- The unit suite is 48 files, 27 `*.test.*` plus 21 `*.spec.*`, and **426 cases**, as jest
enumerates them. Counting only `*.test.*` understates it by twenty-one files, which is the trap
- `en.json` is 180 keys with no ICU plural or select forms
- 136 non-test source files under `app/` excluding typings, mocks and stubs, 8,498 lines; 152 files
and 8,715 lines counting them
- 143 non-test source files under `app/` excluding typings, mocks and stubs; 158 counting them,
which is the figure the bundler section uses
- `profile.ts` (139 lines) is only the iframe host; the view is `profile.tsx` (235) reusing
`Comment` (638) in `view="user"` mode
- `last-comments` renders into the **host page**, not an iframe, and side-loads its own stylesheet
@@ -245,7 +410,7 @@ Checked against the code at `a82dc8d3` and by independent reviewers.
The concrete "what is left" list, and what any no-npm proposal has to answer for.
- transpiles TS and JSX for 136 to 152 source files, typed by `tsconfig.json`, compiled by the
- transpiles TS and JSX for 158 source files, typed by `tsconfig.json`, compiled by the
preset list in `.babelrc.js`
- CSS modules for 29 `*.module.css` files, 2,219 lines, including 177 nested `&`, 4 `composes` and
10 `:global` occurrences across 6 files, all handled by the CSS-modules rule in
@@ -322,21 +487,33 @@ widget *code* through npm breaks OAuth, but it adds a publish step and a version
- [ ] Document `__colors__` from `window.name` (`templates/iframe.ejs`), which works today and is
undocumented
- [ ] Fix the Astro and Gatsby manuals, which declare `REMARK42: any` and `remark_config: any`
- [ ] Correct the published locale list. `site/content/docs/configuration/frontend/_index.md` names
17 languages under `Locales`, and `app/locales/` ships 24 catalogs, so seven are documented
nowhere and a reader cannot discover them
### Task 2: Extend the e2e suite
**Done.** #2180 moved the suite to Go and playwright-go and took it from 7 tests to 22; #2196 took
it to 48. Every item this task originally listed is covered: vote and its failure path
(`vote_test.go`), edit inside and outside the deadline, delete and reply (`comment_test.go`), sort
change and collapse persistence (`thread_test.go`), anonymous and email auth (`auth_test.go`), the
profile iframe and last-comments (`widgets_test.go`).
it to 63, and master now carries 70 runnable top-level tests. Every item this task originally listed
is covered: vote and its failure path (`vote_test.go`), edit inside and outside the deadline, delete
and reply (`comment_test.go`), sort change and collapse persistence (`thread_test.go`), anonymous
and email auth (`auth_test.go`), the 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`,
`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.
Most of what this task once listed as the remaining contract surface has since been covered too,
and the list is kept short here because an out-of-date backlog sends someone to write tests that
exist. Now covered: the cross-origin host page (`crossorigin_test.go` and `https_test.go`), the
`comments.html` fallback and its injection case
(`TestWidgets_CommentsPageOpensAThreadOnItsOwnOrigin`
and `TestWidgets_CommentsPageRefusesInjectedMarkup`), and the `remark_config` fields `url`,
`page_title`, `__colors__`, the subscription flags, timezone rendering and the unknown-locale
fallback, all as `TestConfig_*` cases in `config_test.go`. The composer is substantially covered by
`comment_test.go`, including drafts and a failed post. Repeated `createInstance` is covered at unit
level in `embed.test.ts`, though not end to end.
What is genuinely still uncovered: hash deep links, `max_shown_comments`, Telegram auth and its
subscription flag, and the storage-denied fallback trigger, which `e2e/README.md` explains needs
WebKit. Telegram is blocked on #2208 and `go-pkgz/auth` #316. #2219 adds one more: a deployment
under a path prefix, verifying chunks and assets for every entry including `deleteme`.
### Task 3: Stable class names and a documented override stylesheet
@@ -429,6 +606,10 @@ compiled, it is what verifies it.
(`URLKeyWithUser`, used by `findCommentsCtrl`), one entry per user with a separate `admin!!` key.
An HTML cache fragments the same way, so the shared-cache benefit only pays for logged-out readers
- [ ] Attach the existing auth via `htmx:configRequest` on fragment requests
- [ ] Preserve the explicit height report on panel close. `useDropdown` in `auth.hooks.ts` calls
`updateIframeHeight()` when the sign-in panel closes, added by #2213, because the panel is
absolutely positioned and neither ResizeObserver sees it disappear. A server-rendered replacement
loses the widget's height entirely if it drops that call
- [ ] Keep client-side: embed script, auth popups, composer, collapse and hidden-user state,
optimistic votes, and the **three subscription flows**. Email, Telegram and RSS
(`comment-form/__subscribe-by-email/`, `__subscribe-by-telegram/`, `__subscribe-by-rss/`) are each
@@ -460,7 +641,23 @@ 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, the upstream `Partitioned` work in `go-pkgz/auth`.
subscription flows. R1 costs Path B little: the task list above retains the existing auth through
`htmx:configRequest`, and `request` in `fetcher.ts` already turns `X-JWT` into the partitioned pair,
so Path B can keep that writer or attach the same handling to a response hook.
The upstream `Partitioned` work is the better answer for the flows that can use it. With
`PartitionedCookies` the server sets the pair itself and the `JWT` stays `HttpOnly`, so the session
survives third-party blocking without the token ever becoming readable from script, where the header
route buys the same persistence by giving that readability away.
Two limits keep that from being a free win. The option is global, so `Service.Set` applies it to the
OAuth callback too, and a cookie partitioned to the popup's own top-level context is one the frame
cannot see: turning it on regresses OAuth on the permissive browsers where an unpartitioned
`SameSite=None` cookie works today, so it wants flow scoping or a separate OAuth answer. And the
security gain is narrower than "no XSS exposure": `HttpOnly` stops a script extracting and replaying
the bearer token, but the XSRF value stays readable by design, and script on the widget origin can
still make authenticated requests that the browser attaches the `HttpOnly` cookie to. What it
removes is token theft, not authenticated action during an XSS.
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
+12 -8
View File
@@ -61,7 +61,7 @@ Before pushing, `cd e2e && go vet -tags=e2e ./...` and `golangci-lint run --buil
## The stack
`compose-e2e-test.yml` at the repository root runs six services, each bound to the loopback interface since it holds a known secret and an admin shared id:
`compose-e2e-test.yml` at the repository root runs ten services, each bound to the loopback interface since it holds a known secret and an admin shared id:
- **remark42** on `:8080`, with the dev oauth2 provider on `:8084`, anonymous and email sign-in
- **remark42-shortedit** on `:8081`, with `EDIT_TIME=15s` and anonymous sign-in only, since the dev oauth2 provider's port is fixed at 8084 and cannot be published twice. It exists so the expired-edit path is observable without holding a test open for the default five minutes
@@ -70,11 +70,15 @@ Before pushing, `cd e2e && go vet -tags=e2e ./...` and `golangci-lint run --buil
- **remark42-noauth** on `:8085`, with no auth provider at all, which the widget has to say something about, and with `ALLOWED_HOSTS` set to its own address so it doubles as the instance that refuses to be framed elsewhere
- **remark42-anonvote** on `:8086`, with `ANON_VOTE` and the `VOTES_IP` it depends on, since the default configuration turns an anonymous vote down
- **host-site** on `:8090`, an nginx serving `e2e/hostsite/`, which is a page on an origin the widget is not served from. Every other host page here is served by remark42 itself, so without it the separate-domain setup the manuals describe is never exercised. `post.html` embeds the main instance; `restricted.html` embeds the one whose `ALLOWED_HOSTS` names only itself, which is the refusal case
- **remark42-https** on `:8443`, the widget over TLS with `AUTH_SAME_SITE=none` and `AUTH_SEND_JWT_HEADER=true`. The header mode is what makes the widget write its own cookies through `setAuthCookie`, so the attributes it chooses are observable at all. Anonymous and email sign-in are both enabled, since the third-party cases run each flow: the writer keys off the `X-JWT` header rather than off the provider, and that is an assumption worth measuring rather than asserting
- **host-site-https** on `:8444`, an nginx serving the same `e2e/hostsite/` over TLS, so the embed is cross-site *and* secure. `post-https.html` is its page
- **mailpit** on `:8025`, which catches the email-auth verification message and the subscription token for the suite to read back
Both TLS services read a self-signed certificate from `e2e/tls/`, which `e2e/tls/generate.sh` writes and `.gitignore` keeps out of the tree. `make e2e-up`, the workflow and `ensureStack` all run it before compose, so bringing the stack up by hand with a bare `docker compose up` is the one path that needs it run first. Every browser context and the readiness client accept that certificate, and they talk to nothing else.
The main instance enables the notify module (`NOTIFY_USERS=email`). Without it `email_notifications` is false in the config, the widget never renders the subscribe control, and the whole subscribe, confirm and unsubscribe flow is unreachable from a browser.
The four remark42 instances beyond the first offer anonymous sign-in only, for the reason `remark42-shortedit` does: the dev oauth2 provider binds a port fixed at 8084 and cannot be published twice. `remark42-adminedit` gets its admin from `ADMIN_SHARED_ID`, since the anonymous provider derives the user id from the name and the id for a chosen name can be written into the compose file ahead of time.
The remark42 instances beyond the first offer anonymous sign-in only, for the reason `remark42-shortedit` does: the dev oauth2 provider binds a port fixed at 8084 and cannot be published twice. `remark42-adminedit` gets its admin from `ADMIN_SHARED_ID`, since the anonymous provider derives the user id from the name and the id for a chosen name can be written into the compose file ahead of time.
Three settings exist for the tests and not for realism, and each is there for a reason:
@@ -84,22 +88,22 @@ Three settings exist for the tests and not for realism, and each is there for a
## What this suite cannot reach
Every service here speaks http, and nothing in it holds a certificate. Any behaviour the browser gates on the page protocol is therefore invisible: a cookie the widget writes with `Secure`, anything keyed on `window.location.protocol`, and the whole third-party cookie form of `SameSite=None; Secure; Partitioned`, which is the only one browsers still accept from an embedded frame.
The stack now carries TLS on two services, so behaviour the browser gates on the page protocol is reachable: `Secure` cookies, `SameSite=None`, `Partitioned`, and anything keyed on `window.location.protocol`. `https_test.go` is where those cases live. What is still out of reach is a browser engine other than Chromium for them, since the resolver rules the hostnames need are a Chromium flag.
That is not hypothetical. `setAuthCookie` prefixed its cookies with `__Host-` on any https page, so a real deployment stored `__Host-JWT` while the backend looked for `JWT`; it survived because the prefix comes from the page protocol and every test and the dev server run on http. Fixed in #2197, under a second suite pinned to an https page, because this one cannot show it.
There is a second trap waiting for whoever gives the stack TLS and then tries to prove the third-party case. Playwright's own default `--disable-features` argument carries `ThirdPartyStoragePartitioning`, and it beats both `--test-third-party-cookie-phaseout` and `--block-third-party-cookies` passed through `Args`. A run configured that way keeps an ordinary third-party cookie exactly as it would with no flags at all, so it proves nothing while looking like it proved something. The lever is `IgnoreDefaultArgs` on the launch options: drop that default entry and re-supply `--disable-features` without that one feature. Measured on a cross-site https embed:
The trap that remains is which cookie policy a run is under. Playwright's own default `--disable-features` argument carries `ThirdPartyStoragePartitioning`, and it beats both `--test-third-party-cookie-phaseout` and `--block-third-party-cookies` passed through `Args`. A run configured that way keeps an ordinary third-party cookie exactly as it would with no flags at all, so it proves nothing while looking like it proved something. The lever is `IgnoreDefaultArgs` on the launch options: drop that default entry and re-supply `--disable-features` without that one feature, which is what `TestHTTPS_SessionSurvivesThirdPartyCookieBlocking` does. Measured on a cross-site https embed:
| | ordinary third-party cookie | `Partitioned` cookie |
|---|---|---|
| Playwright defaults | kept | kept |
| partitioning left enabled | dropped | stored, with its partition key |
So a blocking run has to assert a control before anything it reports can be believed: set an ordinary `SameSite=None` cookie from inside the widget frame and require the browser to drop it. If it survives, the run is not blocking anything.
So a blocking run has to assert a control before anything it reports can be believed: set an ordinary `SameSite=None` cookie from inside the widget frame and require the browser to drop it. If it survives, the run is not blocking anything. The argument list is Playwright's own and version-specific, so that control is what keeps it from rotting silently.
That control has to read the cookie back through `document.cookie` in the same frame evaluate that writes it, never through `page.Context().Cookies()`. The two are separate channels with no ordering between them: in the Chromium that Playwright 1.62.1 ships, `CookieJar::SetCookie` queues the write and returns, and the renderer's own `document.cookie` getter is the barrier that forces it to settle, while Playwright's context read is a browser-session `Storage.getCookies` that never touches that frame's jar. A control read that way can find the name absent because the write has not landed, which is the one outcome it exists to rule out. It also writes a valid `Secure; SameSite=None; Partitioned` sentinel and requires that one to be present, so "the browser refused the control" is distinguishable from "nothing was written at all".
None of that reaches the widget's own storage fallback, which the auth panel offers as `comments.html` when `IS_THIRD_PARTY && !IS_STORAGE_AVAILABLE`. `IS_STORAGE_AVAILABLE` stays true even with partitioning properly enforced, because Chromium partitions `localStorage` instead of denying it, so the probe behind that constant never throws and the condition cannot fire. That case needs WebKit, not a Chromium flag.
The practical consequence is for the cross-origin case in `crossorigin_test.go`, which asserts rendering and deliberately not signing in. Give the stack TLS and signing in there becomes testable, and the assertion that matters is **the reload**: the widget holds its token in memory for the life of a page, so a case that signs in and posts without reloading passes while persistence is entirely broken.
Partitioned cookies do not make cross-domain OAuth work, whatever the plan once implied. The partition key is the top-level site at the moment the cookie is set, and `oauthSignin` opens a popup, which is its own top-level context: the callback cookie is keyed to the auth host while the frame is keyed to the embedder, and the two never match. The forms that do work embedded are the ones whose cookie is written inside the frame, which is anonymous, email and telegram. A case asserting OAuth works cross-domain would be asserting something untrue.
## Isolation
+5 -5
View File
@@ -22,11 +22,11 @@ import (
// which means its document loaded and reported itself inited through postMessage across origins,
// and that the thread it renders is the one the page's own address names.
//
// Signing in is deliberately not asserted. An embedded cookie needs SameSite=None, which browsers
// only accept as Secure, and this stack speaks http, so the form cannot be delivered here at all;
// see "What this suite cannot reach" in the README. Give the stack TLS and the case to add is
// signing in and then reloading, since the widget holds its token in memory for the life of a
// page and a sign-in that never reloads passes while persistence is broken
// Signing in is deliberately not asserted here. An embedded cookie needs SameSite=None, which
// browsers only accept as Secure, and this page is served over http, so the form cannot be
// delivered at all. That half is covered over TLS in https_test.go, where the assertion that
// matters is the reload: the widget holds its token in memory for the life of a page, so a
// sign-in that never reloads passes while persistence is broken
func TestCrossOrigin_WidgetRendersOnAnotherOrigin(t *testing.T) {
thread := fmt.Sprintf("%s/post.html?e2e=%s-%s", hostSiteURL, "crossorigin", runID)
text := "cross origin " + runID
+27 -2
View File
@@ -16,6 +16,7 @@
// - embed_test.go: the surface the host page holds, placeholder to destroy
// - config_test.go: the remark_config surface an integrator sets
// - crossorigin_test.go: a host page on an origin the widget is not served from
// - https_test.go: the widget over TLS, where the browser's protocol gates apply
// - deployment_test.go: the instances whose configuration is the thing under test
// - subscribe_test.go: the email subscription round trip
// - webfiles_test.go: the published /web surface
@@ -24,6 +25,7 @@ package e2e
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
@@ -59,6 +61,10 @@ const (
// a page on an origin the widget is not served from, see compose-e2e-test.yml
hostSiteURL = "http://host-site:8090"
// the host page over TLS, which is the only way to reach what the browser gates on the page
// protocol. the certificate is self-signed, so every context passes IgnoreHTTPSErrors
httpsHostSiteURL = "https://host-site-https:8444"
// what this process asks for, since it is not the browser and has no resolver rules
probeURL = "http://127.0.0.1:8080"
shortEditProbeURL = "http://127.0.0.1:8081"
@@ -67,6 +73,8 @@ const (
noAuthProbeURL = "http://127.0.0.1:8085"
anonVoteProbeURL = "http://127.0.0.1:8086"
hostSiteProbeURL = "http://127.0.0.1:8090"
httpsProbeURL = "https://127.0.0.1:8443"
httpsHostProbeURL = "https://127.0.0.1:8444"
mailpitURL = "http://127.0.0.1:8025"
composeFile = "../compose-e2e-test.yml"
@@ -112,7 +120,12 @@ var (
// the default client has no timeout, so a port that accepts and then stalls would block
// a probe well past its own deadline and leave TestMain looking hung
probeClient = &http.Client{Timeout: 5 * time.Second}
probeClient = &http.Client{
Timeout: 5 * time.Second,
// the https services in the stack are self-signed, and this client only ever talks to
// them, so refusing the certificate would only stop the readiness probe
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // test stack
}
)
// everything under /auth/ is rate limited to 2 requests a second, and that figure is a bare
@@ -164,7 +177,8 @@ func TestMain(m *testing.M) {
"--host-resolver-rules=MAP remark42 127.0.0.1, MAP remark42-shortedit 127.0.0.1, " +
"MAP remark42-adminedit 127.0.0.1, MAP remark42-jwtheader 127.0.0.1, " +
"MAP remark42-noauth 127.0.0.1, MAP remark42-anonvote 127.0.0.1, " +
"MAP host-site 127.0.0.1",
"MAP host-site 127.0.0.1, " +
"MAP remark42-https 127.0.0.1, MAP host-site-https 127.0.0.1",
},
})
if err != nil {
@@ -203,6 +217,11 @@ func ensureStack() error {
return assertStackMatches(stamp, true)
}
// the https services will not start without one, and compose cannot make it itself
if out, gerr := exec.Command("./tls/generate.sh").CombinedOutput(); gerr != nil {
return fmt.Errorf("generating the test certificate: %w\n%s", gerr, out)
}
log.Printf("[INFO] no complete stack on 127.0.0.1, bringing one up from %s", composeFile)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
@@ -245,6 +264,8 @@ func stackReady(timeout time.Duration) bool {
noAuthProbeURL + "/ping",
anonVoteProbeURL + "/ping",
hostSiteProbeURL + "/post.html",
httpsProbeURL + "/ping",
httpsHostProbeURL + "/post-https.html",
mailpitURL + "/api/v1/messages",
} {
if err := serverReady(url, timeout); err != nil {
@@ -306,6 +327,10 @@ func newPageOn(t *testing.T, b playwright.Browser) playwright.Page {
// says about itself is the thing under test
func newPageInContext(t *testing.T, b playwright.Browser, opts playwright.BrowserNewContextOptions) playwright.Page {
t.Helper()
// the https services carry a self-signed certificate, and a context that refuses it cannot
// reach them at all. harmless for the http ones
opts.IgnoreHttpsErrors = playwright.Bool(true)
ctx, err := b.NewContext(opts)
require.NoError(t, err)
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>A page on somebody else's site, over TLS</title>
</head>
<body>
<h1>A page on somebody else's site, over TLS</h1>
<p>
Served from a different origin than the comments it embeds, and over https, which is the only
way the widget's cookies can carry Secure, SameSite=None and Partitioned.
</p>
<div id="remark42"></div>
<script>
var remark_config = {
host: 'https://remark42-https:8443',
site_id: 'remark',
components: ['embed'],
url: window.location.href,
};
(function (c, d) {
for (var i = 0; i < c.length; i++) {
var s = d.createElement('script');
s.type = 'module';
s.async = true;
s.defer = true;
s.src = remark_config.host + '/web/' + c[i] + '.mjs';
(d.head || d.body).appendChild(s);
}
})(remark_config.components, document);
</script>
</body>
</html>
+324
View File
@@ -0,0 +1,324 @@
//go:build e2e
package e2e
import (
"fmt"
"os"
"slices"
"strings"
"testing"
"github.com/mxschmitt/playwright-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Everything else in this suite speaks http, which hides an entire class of defect: the browser
// gates Secure cookies, SameSite=None, Partitioned and anything reading location.protocol on the
// page protocol, so code taking those paths is never executed. setAuthCookie decorating its
// cookies with __Host- on https pages survived for exactly that reason.
//
// These run against the TLS pair in compose-e2e-test.yml, which carries a self-signed certificate
// every context here accepts. That instance also runs with AUTH_SEND_JWT_HEADER, so the widget
// writes its own cookies through setAuthCookie: without it the client-side writer never runs on
// any https page in the stack and every assertion about the attributes it chooses is vacuous.
// An unpartitioned third-party cookie is refused only when storage partitioning is enforced, and
// playwright's own default arguments disable it. Dropping that default and re-supplying it without
// the one feature is the only lever; see "What this suite cannot reach" in the README.
//
// The list is playwright's own and therefore version-specific. It is not trusted on its own: the
// case using it asserts a control first, so a list that stops matching fails as itself.
const (
playwrightDisabledFeatures = "--disable-features=AvoidUnnecessaryBeforeUnloadCheckSync," +
"BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider," +
"GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding," +
"ThirdPartyStoragePartitioning,BlockOriginHeaderModificationOnRedirect,Translate,AutoDeElevate," +
"OptimizationHints,msForceBrowserSignIn,msEdgeUpdateLaunchServicesPreferredVersion"
// the one entry that has to go, named once
partitioningFeature = "ThirdPartyStoragePartitioning,"
)
// withPartitioningEnabled is playwright's own list with the one feature removed, derived rather
// than written out again: the two differ by a single entry in three hundred characters, and a
// second copy is a thing to forget on the next playwright bump
func withPartitioningEnabled(t *testing.T) string {
t.Helper()
out := strings.Replace(playwrightDisabledFeatures, partitioningFeature, "", 1)
require.NotEqual(t, playwrightDisabledFeatures, out,
"%q is no longer in playwright's default argument list, so removing it enables nothing and "+
"this browser would block no cookie at all", partitioningFeature)
return out
}
// The flows worth measuring in a third-party frame. OAuth is absent on purpose: 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 never reaches the frame. Telegram is absent because the stack cannot
// answer as Telegram, see #2208.
//
// Email as well as anonymous because the widget's writer keys off the X-JWT header rather than off
// the provider, so the two are expected to behave alike. Expected is not measured, and email is the
// flow an operator running off-domain actually reaches for
var httpsFlows = []struct {
name string
signIn func(t *testing.T, page playwright.Page, frame playwright.FrameLocator)
}{
{"anonymous", func(t *testing.T, page playwright.Page, frame playwright.FrameLocator) {
signInAnon(t, page, frame, anonName("httpsreader"))
}},
{"email", func(t *testing.T, page playwright.Page, frame playwright.FrameLocator) {
// the address decides the user id and the mailbox this reads back, so it carries the run
// id and the case: mailpit keeps everything, and a shared address would let one case read
// the token another asked for
signInEmail(t, page, frame, "httpsemail",
fmt.Sprintf("https-email-%s-%s@example.com", strings.ReplaceAll(t.Name(), "/", "-"), runID))
}},
}
// httpsThread is a thread on the TLS host page, which is a different site from the widget's own
// origin, so the widget's cookies there are genuinely third-party
func httpsThread(t *testing.T, label string) string {
t.Helper()
return fmt.Sprintf("%s/post-https.html?e2e=%s-%s", httpsHostSiteURL, label, runID)
}
// TestHTTPS_CrossOriginSignInSurvivesAReload is what the http cross-origin case cannot assert.
// The widget keeps its token in memory for the life of a page, so signing in and posting proves
// nothing about persistence: only a reload asks whether the cookie was delivered, stored under a
// name the backend reads, and sent back from a third-party frame. On http that cookie cannot even
// be written, since the third-party form requires Secure.
//
// This runs under the browser's default policy, where third-party cookies are allowed.
// TestHTTPS_SessionSurvivesThirdPartyCookieBlocking is the same question with them blocked.
func TestHTTPS_CrossOriginSignInSurvivesAReload(t *testing.T) {
for _, flow := range httpsFlows {
t.Run(flow.name, func(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
_, err := page.Goto(httpsThread(t, "https-signin-"+flow.name))
require.NoError(t, err)
frame := widget(t, page)
flow.signIn(t, page, frame)
text := "posted over tls by " + flow.name + " " + runID
postComment(t, frame, text)
// the assertion the whole file exists for
pauseForAuthLimit()
_, err = page.Reload()
require.NoError(t, err)
frame = widget(t, page)
assertSignedIn(t, page, frame)
waitVisible(t, comment(frame, text))
})
}
}
// TestHTTPS_AuthCookiesCarryTheThirdPartyForm reads the cookies the browser actually stored for
// the widget's origin while it is embedded elsewhere. A cookie the browser refused is not in this
// list at all, and one the browser kept but will not send from a frame is worse than useless, so
// the attributes are the assertion and not the sign-in they enable.
//
// Both cookies are checked and not the JWT alone: the fetcher reads XSRF-TOKEN and returns its
// value as a header, and go-pkgz/auth refuses a cookie-borne token whose header does not match, so
// a JWT arriving beside an XSRF cookie the frame cannot receive authenticates nobody.
func TestHTTPS_AuthCookiesCarryTheThirdPartyForm(t *testing.T) {
page := newPage(t)
pauseForAuthLimit()
_, err := page.Goto(httpsThread(t, "https-cookies"))
require.NoError(t, err)
frame := widget(t, page)
signInAnon(t, page, frame, anonName("httpscookies"))
cookies, err := page.Context().Cookies()
require.NoError(t, err)
// both writers are in play here: the backend sets its own pair, and the widget sets a
// partitioned pair of the same names through setAuthCookie. Every copy has to be usable from
// a third-party frame, and the partitioned one has to exist, or the assertions below would be
// reading the server's cookie and saying nothing about the client's
for _, name := range []string{"JWT", "XSRF-TOKEN"} {
var partitioned int
var seen int
for _, c := range cookies {
if c.Name != name {
continue
}
seen++
assert.True(t, c.Secure, "a %s cookie is not Secure, so a third-party frame can never receive it", name)
require.NotNil(t, c.SameSite, "a %s cookie carries no SameSite at all", name)
assert.Equal(t, *playwright.SameSiteAttributeNone, *c.SameSite,
"a %s cookie is not SameSite=None, and SameSite is judged against the top-level site, so "+
"anything stricter is never sent from an embedded widget", name)
if c.PartitionKey != nil {
partitioned++
}
}
require.NotZero(t, seen, "no %s cookie was stored at all, so the browser refused what was sent: %v",
name, cookieNames(cookies))
assert.NotZero(t, partitioned,
"no partitioned %s cookie was stored, so setAuthCookie either did not run or chose attributes "+
"a blocking browser will drop", name)
}
// the XSRF value has to be readable by the widget's own script, which is what puts it in the
// header the backend matches the token against
for _, c := range cookies {
if c.Name == "XSRF-TOKEN" {
assert.False(t, c.HttpOnly, "the XSRF cookie has to be readable by the widget's own script")
}
}
// no name the server does not read. A __Host- prefixed cookie is a perfectly valid cookie the
// browser stores happily, which is why writing one is a defect nothing rejects: the backend
// looks for JWT and the fetcher reads XSRF-TOKEN, and neither finds a decorated name.
//
// this can only fail because the instance runs with AUTH_SEND_JWT_HEADER, which is what makes
// the widget write cookies of its own instead of leaving the server's pair alone.
for _, c := range cookies {
assert.NotContains(t, c.Name, "__Host-",
"a __Host- prefixed cookie was stored, and nothing on either side reads that name")
}
}
// TestHTTPS_SessionSurvivesThirdPartyCookieBlocking is the reload question under the policy that
// makes it hard. With storage partitioning enforced an ordinary third-party cookie is dropped, so
// the session survives only because the widget's own cookies carry Partitioned; the server's pair
// does not, and vanishes.
//
// The control comes first and is not decoration: playwright's default arguments disable the policy
// outright, so a run configured wrongly keeps every third-party cookie and this case would pass
// while asserting nothing at all.
func TestHTTPS_SessionSurvivesThirdPartyCookieBlocking(t *testing.T) {
blocking, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
Headless: playwright.Bool(os.Getenv("E2E_HEADLESS") != "false"),
IgnoreDefaultArgs: []string{playwrightDisabledFeatures},
Args: []string{
withPartitioningEnabled(t),
"--test-third-party-cookie-phaseout",
"--host-resolver-rules=MAP remark42-https 127.0.0.1, MAP host-site-https 127.0.0.1",
},
})
require.NoError(t, err)
t.Cleanup(func() { _ = blocking.Close() })
for _, flow := range httpsFlows {
t.Run(flow.name, func(t *testing.T) {
// a context of its own, so neither case inherits what the other stored
page := newPageOn(t, blocking)
pauseForAuthLimit()
_, err := page.Goto(httpsThread(t, "https-blocked-"+flow.name))
require.NoError(t, err)
frame := widget(t, page)
assertPartitioningEnforced(t, page)
flow.signIn(t, page, frame)
// the chain this case rests on, asserted before the reload can fail on the end of it:
// the server sends X-JWT only under AUTH_SEND_JWT_HEADER, setAuthCookie writes its own
// pair only when the fetcher sees that header, and only that pair carries Partitioned,
// since the server's own cookies carry none. drop the flag from the service and the
// reload below signs nobody in, which reads as a defect in cookie handling instead of a
// stack that cannot test it
written, err := page.Context().Cookies()
require.NoError(t, err)
require.True(t, slices.ContainsFunc(written, func(c playwright.Cookie) bool {
return c.Name == "JWT" && c.PartitionKey != nil
}), "no partitioned JWT cookie was written before the reload, so the widget's own writer never ran: "+
"remark42-https is most likely no longer configured with AUTH_SEND_JWT_HEADER. stored: %v",
cookieNames(written))
pauseForAuthLimit()
_, err = page.Reload()
require.NoError(t, err)
frame = widget(t, page)
assertSignedIn(t, page, frame)
// and it survived because the widget's own cookies are partitioned, which is the only
// form a blocking browser keeps
cookies, err := page.Context().Cookies()
require.NoError(t, err)
for _, name := range []string{"JWT", "XSRF-TOKEN"} {
var stored *playwright.Cookie
for i, c := range cookies {
if c.Name == name {
stored = &cookies[i]
break
}
}
require.NotNil(t, stored, "no %s cookie survived the blocking browser: %v", name, cookieNames(cookies))
assert.NotNil(t, stored.PartitionKey,
"the %s cookie is not partitioned, so it only survived because the browser was not blocking", name)
}
})
}
}
// assertPartitioningEnforced proves the browser refuses an ordinary third-party cookie before any
// case leans on it. Both cookies are written and read back inside a single frame evaluate, and the
// read is `document.cookie` rather than the context's cookie list, because the two are different
// channels with no ordering between them.
//
// Pinned to a build rather than stated as a rule: playwright 1.62.1 ships chromium 151.0.7922.34,
// where Blink's CookieJar::SetCookie queues the write and returns without a completion callback,
// and the renderer's own document.cookie getter is the barrier that forces the pending write to
// settle. Playwright's Context().Cookies() is a browser-session Storage.getCookies that never
// touches that frame's jar, so reading the control through it can find the name absent because the
// write has not landed yet, which is precisely the outcome the control exists to rule out. An
// implementation is free to serialize both calls in the browser process, so this is what to
// re-check on a playwright bump.
//
// The sentinel is the positive half and it has to come first: a valid third-party cookie proves the
// write path and the partitioned path are both live, and only then does the control's absence mean
// the browser turned it down rather than that nothing was written at all
func assertPartitioningEnforced(t *testing.T, page playwright.Page) {
t.Helper()
const (
sentinel = "e2esentinel"
control = "e2econtrol"
)
// one evaluate: both writes, then the getter that settles them, returned as separate answers so
// a missing sentinel and a surviving control stay distinguishable
raw, err := page.FrameLocator("#remark42 iframe").Locator("body").Evaluate(`() => {
document.cookie = 'e2econtrol=1; Path=/; Secure; SameSite=None';
document.cookie = 'e2esentinel=1; Path=/; Secure; SameSite=None; Partitioned';
const names = document.cookie.split(';').map((c) => c.trim().split('=')[0]);
return { sentinel: names.includes('e2esentinel'), control: names.includes('e2econtrol'), all: names };
}`, nil)
require.NoError(t, err)
got, ok := raw.(map[string]any)
require.True(t, ok, "expected an object from the frame, got %T (%v)", raw, raw)
require.Equal(t, true, got["sentinel"],
"a %s cookie in the third-party form the browser still accepts was not stored, so nothing was "+
"written at all and the absence of %s below would mean nothing. cookies in the frame: %v",
sentinel, control, got["all"])
require.Equal(t, false, got["control"],
"an ordinary third-party %s cookie survived alongside the partitioned %s, so this browser is "+
"not partitioning storage and nothing here is being tested. playwright's default argument "+
"list has most likely changed: see playwrightDisabledFeatures. cookies in the frame: %v",
control, sentinel, got["all"])
}
func cookieNames(cookies []playwright.Cookie) []string {
out := make([]string, 0, len(cookies))
for _, c := range cookies {
out = append(out, c.Name)
}
return out
}
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# Self-signed certificate for the https services in the e2e stack.
#
# The suite passes IgnoreHTTPSErrors, so the certificate is never validated and its contents do
# not have to satisfy anything. It carries the right names anyway, which removes the name mismatch
# from the list of complaints when somebody looks at the stack by hand. The issuer is still unknown,
# so curl needs -k and a browser needs an exception either way.
#
# Regenerated only when missing: a fresh certificate on every stack-up would give the running
# services one the containers were not started with, since nginx and remark42 read it once.
set -eu
cd "$(dirname "$0")"
if [ -f cert.pem ] && [ -f key.pem ]; then
exit 0
fi
# stdout only. openssl writes its progress to stderr and its errors there too, and dropping both
# leaves every caller with a bare exit code: the CI step, `make e2e-up` and ensureStack all print
# nothing about why the certificate could not be made
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout key.pem -out cert.pem \
-subj "/CN=remark42-e2e" \
-addext "subjectAltName=DNS:remark42-https,DNS:host-site-https,DNS:localhost,IP:127.0.0.1" \
>/dev/null
chmod 644 key.pem cert.pem
+22
View File
@@ -0,0 +1,22 @@
# the published listener, which is what the suite and the browser use
server {
listen 443 ssl;
server_name host-site-https;
ssl_certificate /etc/nginx/tls/cert.pem;
ssl_certificate_key /etc/nginx/tls/key.pem;
root /usr/share/nginx/html;
index post-https.html;
}
# for the container healthcheck only, and not published. busybox wget, which is all this image
# carries, has no TLS, so a check against the listener above cannot tell "not started yet" from
# "cannot speak https at all"
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index post-https.html;
}
-2
View File
@@ -10,8 +10,6 @@ if (document.readyState === 'loading') {
}
async function init(): Promise<void> {
__webpack_public_path__ = `${window.location.origin}/web/`;
const node = document.getElementById(NODE_ID);
if (!node) {
@@ -78,9 +78,9 @@ services:
| image.resize-width | IMAGE_RESIZE_WIDTH | `2400` | width of a resized image |
| image.resize-height | IMAGE_RESIZE_HEIGHT | `900` | height of a resized image |
| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | JWT TTL |
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL |
| auth.send-jwt-header | AUTH_SEND_JWT_HEADER | `false` | send JWT as a header instead of a server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie. [See security considerations](#security-considerations-for-authsend-jwt-header). |
| auth.same-site | AUTH_SAME_SITE | `default` | set same site policy for cookies (`default`, `none`, `lax` or `strict`) |
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | TTL of the server-set auth cookie. Note it does not govern the cookie the frontend writes under `auth.send-jwt-header`, which is fixed at 200h |
| auth.send-jwt-header | AUTH_SEND_JWT_HEADER | `false` | also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; the server-set cookies are still sent. [See security considerations](#security-considerations-for-authsend-jwt-header). |
| auth.same-site | AUTH_SAME_SITE | `default` | SameSite attribute for the server-set auth cookies (`default`, `none`, `lax` or `strict`). `default` emits no attribute at all and leaves the choice to the browser, which is not the same as `lax` |
| auth.apple.cid | AUTH_APPLE_CID | | Apple client ID (App ID or Services ID) |
| auth.apple.tid | AUTH_APPLE_TID | | Apple service ID |
| auth.apple.kid | AUTH_APPLE_KID | | Apple Private key ID |
@@ -194,9 +194,14 @@ When `auth.send-jwt-header=true` is enabled:
- **Security Impact**: JWT tokens are stored in client-accessible cookies that can be accessed by JavaScript
- **Vulnerability**: This increases vulnerability to XSS attacks compared to server-set HttpOnly cookies
- **Implementation Mitigations**:
- SameSite=Strict cookies to prevent CSRF attacks
- `SameSite=Strict` when the widget and the page share an origin, which is what prevents the
cookie being sent from another site
- `SameSite=None; Secure; Partitioned` when the widget is embedded on another domain, where
`Strict` would never be sent at all. `Partitioned` keys the cookie to the embedding top-level
site, so a different site gets a separate cookie and cannot reach this one. Pages and
subdomains under that same site do share it, since the partition key is the site rather than
the page
- Secure flag automatically added on HTTPS connections
- __Host- prefix added on HTTPS to prevent subdomain attacks
- Double Submit Cookie pattern with XSRF token matching the JWT ID
This configuration should only be used when:
@@ -6,23 +6,78 @@ title: Configure Instance on a different domain
### What doesn't work so far?
Unless discussion [#1139](https://github.com/umputun/remark42/discussions/1139) has a marked answer, authorisation using oAuth like GitHub or Google is impossible on domains other than the original one. Telegram, Email and anonymous auth would work everywhere.
Unless discussion [#1139](https://github.com/umputun/remark42/discussions/1139) has a marked answer, authorisation using oAuth like GitHub or Google is impossible on domains other than the original one. Telegram, Email and anonymous auth work on allowed HTTPS embedding domains when `AUTH_SEND_JWT_HEADER=true`.
### Setup
Set `ALLOWED_HOSTS="'self',https://example1.org,https://example2.org"` with your domain names and `AUTH_SAME_SITE=none`.
Set `ALLOWED_HOSTS="'self',https://example1.org,https://example2.org"` with your domain names, and `AUTH_SEND_JWT_HEADER=true`.
`AUTH_SEND_JWT_HEADER` is what keeps a reader signed in across a page reload on Safari, in Chrome Incognito, and in any browser configured to block third-party cookies. Read [its security considerations](../../configuration/parameters/#security-considerations-for-authsend-jwt-header) before enabling it: it puts the token in a cookie JavaScript can read, which costs XSS exposure that a server-set `HttpOnly` cookie does not.
**`AUTH_SAME_SITE=none` is not needed alongside it**, which reverses what this page recommended for years, so it is worth showing the measurement instead of asserting it. Signing in anonymously from an embedded widget over https and dumping the browser's cookie jar gives, with the setting:
| name | httpOnly | partition key | written by |
| --- | --- | --- | --- |
| `JWT` | yes | none | the server |
| `XSRF-TOKEN` | no | none | the server |
| `JWT` | no | the embedding site | the widget |
| `XSRF-TOKEN` | no | the embedding site | the widget |
and without it, only the widget's own partitioned pair. Sign-in, posting and the reload all work either way, in a permissive browser and in one blocking third-party cookies. What the setting adds is the unpartitioned `HttpOnly` `JWT` in the first row, delivered as a third-party cookie to every listed domain wherever the browser still permits that. Nothing needs it, so leaving it at the default is the smaller exposure.
Keep `AUTH_SAME_SITE=none` if you are *not* setting `AUTH_SEND_JWT_HEADER`. Then the server's cookies are the only ones there are, and this is what allows them to be set off-domain at all, for as long as the reader's browser still accepts unpartitioned third-party cookies.
The `'self'` in `ALLOWED_HOSTS` value means "domain where Remark42 is installed on" and needed if you want `remark42.example.com/web/` to work in case you want to test something with it.
### Technical details
`ALLOWED_HOSTS` sets CSP [frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors), which, once enabled, limits the domains where Remark42 would work. The default value is `*` so that it would work on any domain`.
`ALLOWED_HOSTS` sets CSP [frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors), which, once enabled, limits the domains where Remark42 would work. The default value is `*` so that it would work on any domain.
`AUTH_SAME_SITE` sets the [SAME_SITE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) attribute for authorisation cookies, allowing Remark42 either on the original domain and subdomains there (default value, which equals to `Lax`) or allows setting authorisation cookies on any domain where remark42 is shown (`None` setting).
`AUTH_SAME_SITE` sets the [SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) attribute on the cookies the server sets. `none` lets those cookies be set on any domain where Remark42 is shown.
The `default` setting does not mean `Lax`. It means Remark42 emits no `SameSite` attribute at all and leaves the choice to the browser, and browsers differ: Chromium treats a missing attribute as `Lax` and so refuses the cookie cross-site, while Firefox accepts it. That difference is not a detail, because it decides which of the two cookie pairs below a reader actually ends up with.
`SameSite=None` is not sufficient on its own, and with `AUTH_SEND_JWT_HEADER` it is not necessary either. A browser that blocks third-party cookies drops a cookie set by Remark42 for a reader on another domain no matter what its `SameSite` value is, unless the cookie is explicitly marked [`Partitioned`](https://developer.mozilla.org/en-US/docs/Web/Privacy/Privacy_sandbox/Partitioned_cookies), and the server-set cookies are not. `AUTH_SEND_JWT_HEADER=true` is what closes that: the token comes back in an `X-JWT` response header and the widget stores it in its own cookie, written from inside the embedded frame and marked `SameSite=None; Secure; Partitioned`, so the browser keeps it for that embedding site and sends it back after a reload. That cookie is the widget's own doing and owes nothing to `AUTH_SAME_SITE`, which reaches only the pair the server sets.
A browser that refuses a cross-site `Set-Cookie` lacking `SameSite=None` refuses it outright, so with the setting left at its default the server's pair is absent from the jar, not present with a stricter attribute.
Note that this applies to Email, Telegram and anonymous authorisation, which the widget performs from inside the frame. It does not rescue oAuth, which completes in a popup that is a top-level page of its own, so the cookie set there belongs to the Remark42 domain and the embedded frame never sees it.
### What each browser actually does
Measured on real domains over real certificates, with Remark42 on one registrable domain and the
host page on another, signing in and then reloading. Every "blocked" column below was verified with
a control cookie: an ordinary third-party cookie written from inside the widget frame has to be
dropped, or the run is not blocking anything and proves nothing.
| configuration | Chrome, default | Chrome, third-party cookies blocked | Firefox, default | Firefox, "block all third-party" | Safari |
| --- | --- | --- | --- | --- | --- |
| `AUTH_SEND_JWT_HEADER` only | works | works | works | fails | works |
| `AUTH_SAME_SITE=none` only | works | fails | works | fails | fails |
| both | works | works | works | fails | works |
Three things in that table are worth spelling out.
**Safari needs no configuring to break the old recipe.** It blocks third-party cookies out of the
box, so `AUTH_SAME_SITE=none` on its own has already stopped working there for every reader. This
is not a future deprecation to plan for.
**Firefox reaches "works" by a different route, and a weaker one.** Chrome and Safari refuse the
server's cookie when it carries no `SameSite` attribute, which leaves the field clear for the
widget to write its own partitioned pair. Firefox accepts that cookie, and because the server's
`JWT` is `HttpOnly`, the browser then refuses to let the widget's script overwrite it: a cookie set
by JavaScript may not replace an `HttpOnly` one of the same name. So on Firefox the session rides
on an ordinary unpartitioned third-party cookie even with the header flag on, and it disappears the
moment the reader blocks those.
**No configuration survives Firefox's "block all third-party cookies" setting.** That mode discards
partitioned cookies too, so the `Partitioned` escape hatch does not apply. A reader who has turned
it on cannot stay signed in on an embedded widget, and nothing in Remark42 can change that.
Here are all possible combinations of these two:
- Default setup with unaltered variables: comments are shown on any domain, but the authorisation wouldn't work anywhere, except on the same domain Remark42 is installed on and subdomains of it.
- `ALLOWED_HOSTS` set to a set of domains: comments are shown only on listed domains, and authorisation wouldn't work anywhere, expect on the same domain Remark42 is installed on and subdomains of it.
- `AUTH_SAME_SITE` set to `None`: comments are shown on any domain. The authorisation would work anywhere.
- `ALLOWED_HOSTS` set to a set of domains and `AUTH_SAME_SITE` set to `None`: comments are shown on listed domains. The authorisation would work on all of them.
- `ALLOWED_HOSTS` set to a set of domains: comments are shown only on listed domains, and authorisation wouldn't work anywhere, except on the same domain Remark42 is installed on and subdomains of it.
- `AUTH_SAME_SITE` set to `None`: comments are shown on any domain. Authorisation works on browsers that still permit third-party cookies, and stops working on the ones that block them.
- `ALLOWED_HOSTS` set to a set of domains and `AUTH_SAME_SITE` set to `None`: comments are shown on listed domains, with the same authorisation caveat.
- `ALLOWED_HOSTS` and `AUTH_SEND_JWT_HEADER=true`, with `AUTH_SAME_SITE` left alone: comments are shown on listed domains, and Email, Telegram and anonymous authorisation survives a reload whatever the browser's third-party cookie policy. This is the recommended arrangement. Adding `AUTH_SAME_SITE=none` on top changes nothing about whether a reader stays signed in; it only adds the server's unpartitioned cookies where the browser still takes them.