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.
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -79,7 +79,7 @@ services:
|
||||
| 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.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` | 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 |
|
||||
@@ -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:
|
||||
|
||||
@@ -10,7 +10,22 @@ Unless discussion [#1139](https://github.com/umputun/remark42/discussions/1139)
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -20,9 +35,16 @@ The `'self'` in `ALLOWED_HOSTS` value means "domain where Remark42 is installed
|
||||
|
||||
`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).
|
||||
|
||||
`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.
|
||||
|
||||
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.
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user