Compare commits

..
Author SHA1 Message Date
Dmitry Verkhoturov d534247c5a Pin the public path #2203 fixed, which shipped without a test
The bundler used to bake a fixed public path into every entry, so an
instance mounted under a prefix, which manuals/subdomain documents,
asked the domain root for its provider icons and got nothing. #2197
derived the path from the URL the bundle was loaded from instead, and
covered it with nothing: publicPath appears only in webpack.config.js
and neither suite touched asset paths.

The check is on the assignment webpack emits for its runtime public
path, a string literal when the path is fixed and an expression when it
is derived. Asset filenames are bare in both builds, so the obvious
assertion, looking for a rooted "/web/name.svg" string, finds nothing
either way and proves nothing. I wrote that one first and it passed
against a deliberately broken build.

Every emitted bundle is checked rather than the entry the bug was
reported against: it put fifteen icons in remark.mjs and one in
last-comments.mjs, so a case reading a single entry would have gone
green with half of it still live.

Verified by rebuilding the frontend with the public path baked back in:
the assertion fails on that build and passes on master's.
2026-08-23 21:22:42 +01:00
Dmitry Verkhoturov 75e1b69342 Judge the iframe reveal by the mark, not by when the read lands
Three cases separate a reveal that came from the inited message from one that
came from the five second fallback, and two of them did it against a clock this
process holds. That is a bet on how fast an engine is, and both bets lose on a
loaded machine: TestIframe_StaysHiddenUntilTheDocumentReportsInited polled the
DOM after a loop bounded 500ms below the fallback, and an evaluate round trip
outlasts that margin, so a fallback firing exactly on time reads as an early
reveal; TestIframe_IsRevealedByTheInitedMessage capped the message path at 3s,
and webkit has reported inited 2.6s after creation here.

All three now judge the recorded reveal against one cutoff, half a second under
the fallback. A timer cannot fire early, so below it the reveal can only be the
message and above it only the fallback, and the mark carries the moment itself,
so a slow read cannot move it. The hidden case drops its polled visibility
assertion for the same mark, which is what the observer records in the first
place.
2026-08-23 21:22:42 +01:00
7 changed files with 71 additions and 102 deletions
+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:"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)"`
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)"`
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"`
+1 -4
View File
@@ -271,14 +271,11 @@ 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
+1 -4
View File
@@ -573,12 +573,9 @@ 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
+19 -21
View File
@@ -96,9 +96,12 @@ const (
// be satisfied by the fallback alone and says nothing about the message path. bound the
// message-path assertions well under it
revealTimeout = 5 * time.Second
// generous enough for a cold navigation on a loaded runner, and still well under the
// fallback, which is the point of the assertion
messageRevealBudget = 3 * time.Second
// the line the three reveal cases are judged against, measured from the element's creation
// and not from anything this process can time. a timer cannot fire early, so below this the
// reveal can only have come from the message, and above it only from the fallback. a budget
// chosen for how long an engine takes instead would be a bet on the slowest one: webkit has
// reported inited 2.6s after creation on a loaded machine
revealCutoff = revealTimeout - 500*time.Millisecond
)
// openWithBlockedIframeDoc loads the demo page with the widget document aborted, so the
@@ -242,21 +245,19 @@ func TestIframe_StaysHiddenUntilTheDocumentReportsInited(t *testing.T) {
forEachEngine(t, func(t *testing.T, page playwright.Page) {
openWithBlockedIframeDoc(t, page)
// unconditional: the loop below is bounded by the frame's own age, and on a slow
// enough load that bound can already be spent, which would leave the test asserting
// nothing at all about visibility
require.Equal(t, "hidden", iframeVisibility(t, page))
// then hold it for almost the whole fallback window. stopping halfway would only prove
// the fallback is not shorter than that, and a widget that revealed on anything other
// than `inited` would still pass. measured from the element's creation, since that is
// when the fallback it must not have used starts counting
for iframeAge(t, page) < revealTimeout-500*time.Millisecond {
require.Equal(t, "hidden", iframeVisibility(t, page))
// hold for almost the whole fallback window. stopping halfway would only prove the
// fallback is not shorter than that, and a widget that revealed on anything other than
// `inited` would still pass
for iframeAge(t, page) < revealCutoff {
time.Sleep(100 * time.Millisecond)
}
_, revealed := revealDelay(t, page)
assert.False(t, revealed, "the frame was revealed before its document reported inited")
// the mark and not what is visible when the read lands: the two are separated by the
// margin above, and an evaluate round trip on a loaded engine outlasts it, which reads
// a fallback that fired exactly on time as an early reveal
delay, revealed := revealDelay(t, page)
assert.False(t, revealed && delay < revealCutoff,
"the frame was revealed %v after it was created, before its document reported inited", delay)
})
}
@@ -290,7 +291,7 @@ func TestIframe_IsRevealedByTheInitedMessage(t *testing.T) {
// whole 5s window without the widget being at fault
delay, ok := revealDelay(t, page)
require.True(t, ok, "the frame reported no reveal at all")
assert.Less(t, delay, messageRevealBudget,
assert.Less(t, delay, revealCutoff,
"the reveal was slow enough to have come from the fallback and not the message")
waitVisible(t, page.Locator("#remark42 iframe"))
})
@@ -310,12 +311,9 @@ func TestIframe_IsRevealedByTheTimeoutWhenInitedNeverArrives(t *testing.T) {
// and not before it: without a lower bound, shortening the fallback to a value that
// defeats its purpose would still pass. against the frame's own clock, so that a slow
// navigation cannot be mistaken for the timer having run
// close to the fallback and not three quarters of it: measured in the page there is
// no navigation to make room for, and a wider floor tolerates a fallback shortened
// enough to defeat its purpose
delay, ok := revealDelay(t, page)
require.True(t, ok, "the frame reported no reveal at all")
assert.Greater(t, delay, revealTimeout-500*time.Millisecond,
assert.Greater(t, delay, revealCutoff,
"the reveal came too early to have been the fallback timer")
})
}
+37
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/exec"
"regexp"
"slices"
"strings"
"sync"
@@ -264,3 +265,39 @@ func pauseForWebLimit() {
}
lastWebGet = time.Now()
}
// TestWeb_NoBundleHardcodesTheWebRoot pins the fix for #2203, which shipped with no test at any
// level. The bundler used to bake a fixed public path into every entry, so an instance mounted
// under a prefix, which manuals/subdomain documents, asked the domain root for its provider icons
// and got nothing. The path is derived from the URL the bundle was loaded from now, which is
// correct for both arrangements.
//
// What separates the two builds is the assignment webpack emits for its runtime public path: a
// literal when the path is fixed, and a computed value when it is derived. Asset filenames appear
// bare either way, so a case looking for a rooted "/web/name.svg" string finds nothing in either
// build and proves nothing; this asserts the assignment instead.
//
// Every emitted bundle is checked rather than the obvious one: the original defect put fifteen
// icons in remark.mjs and one in last-comments.mjs, so a case reading a single entry would have
// gone green with half of it still live.
func TestWeb_NoBundleHardcodesTheWebRoot(t *testing.T) {
names := emittedBundles(t)
require.Contains(t, names, "remark.mjs", "listing is not the served web root: %v", names)
require.Contains(t, names, "last-comments.mjs",
"the entry carrying the second half of #2203 is missing from the listing: %v", names)
// webpack writes its public path to the `p` property of the runtime object. A baked-in path
// is a string literal there; a derived one is an expression
baked := regexp.MustCompile("\\.p\\s*=\\s*[\"'`]/web/[\"'`]")
for _, name := range names {
t.Run(name, func(t *testing.T) {
body := getWeb(t, "/web/"+name).body
require.NotEmpty(t, body, "%s serves nothing, so this asserts nothing", name)
assert.NotRegexp(t, baked, body,
"%s bakes the public path in rather than deriving it, so an instance mounted under "+
"a prefix fetches its assets from the domain root", name)
})
}
}
@@ -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` | 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.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.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,14 +194,9 @@ 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` 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
- SameSite=Strict cookies to prevent CSRF attacks
- 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,78 +6,23 @@ 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 work on allowed HTTPS embedding domains when `AUTH_SEND_JWT_HEADER=true`.
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.
### Setup
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.
Set `ALLOWED_HOSTS="'self',https://example1.org,https://example2.org"` with your domain names and `AUTH_SAME_SITE=none`.
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 [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.
`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).
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, 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.
- `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.