Files
remark42/compose-e2e-test.yml
T
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

337 lines
12 KiB
YAML

# compose for the e2e suite; the tests themselves run on the host, see e2e/
#
# every port is bound to the loopback interface on purpose. this stack runs with a known
# secret, dev oauth2 and an admin shared id, and `go test` can start it unattended, so it
# must not be reachable from the network. the suite only ever talks to 127.0.0.1.
services:
remark42:
build:
context: .
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e"
ports:
- "127.0.0.1:8080:8080"
# dev oauth2 provider, registered from the REMARK_URL hostname
- "127.0.0.1:8084:8084"
# the hostname has to be a name, not 127.0.0.1: the dev oauth2 server binds the
# host it reads out of REMARK_URL, and a loopback bind inside the container cannot be
# published. the suite maps the name back to 127.0.0.1 in the browser, see e2e/
environment:
- REMARK_URL=http://remark42:8080
- SECRET=12345
- DEBUG=true
- ADMIN_PASSWD=password
- AUTH_DEV=true # local oauth2 "dev" provider, bound to the REMARK_URL host on :8084
# ADMIN_EDIT stays off: it gives admins an infinite edit window, which removes the
# countdown TestComment_EditWithinTheDeadline asserts on
- ADMIN_SHARED_ID=dev_user # set admin flag for default user on local oauth2
- AUTH_ANON=true
- AUTH_EMAIL_ENABLE=true
# the auth sender reads AUTH_EMAIL_FROM, the notify module reads NOTIFY_EMAIL_FROM. both
# go to mailpit, which the suite reads back over HTTP
- AUTH_EMAIL_FROM=remark42@example.com
# without this email_notifications is false and the subscribe control never renders, so
# the whole subscribe, confirm and unsubscribe flow is unreachable from a browser
- NOTIFY_USERS=email
- NOTIFY_EMAIL_FROM=notify@example.com
- SMTP_HOST=mailpit
- SMTP_PORT=1025
# default is 0.5/sec, which the suite exceeds whenever a test posts twice in a row
- UPDATE_LIMIT=100
# digest of the sources this stack was brought up from, from e2e/stamp.sh, which the suite
# reads back and compares against the checkout it is running in. every checkout builds the
# image tag above, so a stack from another worktree answers on these ports and passes every
# readiness probe while serving code nobody is looking at. remark42 ignores it.
#
# set as the environment and not as a build argument on purpose: an argument reaching
# the backend stage is part of the go build's cache key, so every frontend edit would
# rebuild the backend too. the cost is that `docker compose up` without --build would stamp
# a stale image, which neither `make e2e-up` nor the suite does
- E2E_SOURCE_STAMP=${E2E_STAMP:-unstamped}
volumes:
- remark42-e2e-var:/srv/var
depends_on:
mailpit:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# a second instance whose edit window expires almost immediately, so the expired-edit
# path can be exercised without holding a test open for the default five minutes
remark42-shortedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-shortedit"
# this service has no build of its own, so without this a pull could substitute the
# published image for the one built from this checkout
pull_policy: never
# only needs the image the first service builds, not a running one
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8081:8080"
# anonymous only: the dev oauth2 provider's port is hardcoded to 8084 against the
# REMARK_URL hostname, so a second instance on the same host would register the
# first one's provider
environment:
- REMARK_URL=http://remark42-shortedit:8081
- SECRET=12345
- AUTH_ANON=true
# long enough that the post round trip and the first assertion comfortably fit inside
# the window, short enough that waiting it out costs a few seconds
- EDIT_TIME=15s
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-shortedit-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# ADMIN_EDIT gives admins an unlimited edit window, which #1986 reported the frontend ignoring.
# a separate instance because the setting is global and removes the countdown the ordinary
# deadline cases assert on
remark42-adminedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-adminedit"
pull_policy: never
depends_on:
remark42:
condition: service_started
mailpit:
condition: service_healthy
ports:
- "127.0.0.1:8082:8080"
environment:
- REMARK_URL=http://remark42-adminedit:8082
- SECRET=12345
- ADMIN_EDIT=true
# email auth, because its user id is the only one that can be written down ahead of time.
# the dev provider's port is fixed at 8084 and cannot be published twice, and the anonymous
# id is hashed from the name AND the client address (server.go:1220), which differs between
# a laptop and a runner, so an id pinned here would make an admin of nobody on CI.
# the email id is sha1 of the address alone (go-pkgz/auth provider/verify.go:92), so this is
# email_$(printf adminedit@example.com | shasum) and the test signs in as that address
- AUTH_EMAIL_ENABLE=true
- AUTH_EMAIL_FROM=remark42@example.com
- SMTP_HOST=mailpit
- SMTP_PORT=1025
- ADMIN_SHARED_ID=email_0212c0d9ebf846e898888d617f82d4147747bdc3
# short, so "after the deadline" is a few seconds instead of the default five minutes
- EDIT_TIME=15s
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-adminedit-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# AUTH_SEND_JWT_HEADER moves the token out of the cookie and into a header the frontend has to
# keep for itself, which #1877 reported losing on every reload
remark42-jwtheader:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-jwtheader"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8083:8080"
environment:
- REMARK_URL=http://remark42-jwtheader:8083
- SECRET=12345
- AUTH_ANON=true
- AUTH_SEND_JWT_HEADER=true
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-jwtheader-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# no auth provider at all, which #1456 reported rendering as a widget with nothing to say. it
# cannot be a setting on another instance: the absence is the configuration
remark42-noauth:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-noauth"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8085:8080"
environment:
- REMARK_URL=http://remark42-noauth:8085
- SECRET=12345
# its own address and nothing else, so this instance also serves as the negative case for
# embedding: the browser refuses to frame it anywhere but here, and the widget document
# never runs to report itself inited
- ALLOWED_HOSTS=http://remark42-noauth:8085
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-noauth-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# anonymous voting, which the default configuration refuses: rest_private.go turns down a vote
# from an anonymous user unless ANON_VOTE is set, and it only works alongside VOTES_IP, which
# is what scopes it
remark42-anonvote:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-anonvote"
pull_policy: never
depends_on:
remark42:
condition: service_started
ports:
- "127.0.0.1:8086:8080"
environment:
- REMARK_URL=http://remark42-anonvote:8086
- SECRET=12345
- AUTH_ANON=true
- ANON_VOTE=true
- VOTES_IP=true
- UPDATE_LIMIT=100
volumes:
- remark42-e2e-anonvote-var:/srv/var
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
interval: 2s
timeout: 3s
retries: 30
# a page on an origin the widget is not served from, which is the separate-domain setup the
# manuals describe and the one readers actually hit problems with. every other host page in the
# suite is served by remark42 itself, so nothing else exercises a cross-origin embed
host-site:
image: nginx:1.29-alpine
container_name: "remark42-e2e-hostsite"
ports:
- "127.0.0.1:8090:80"
volumes:
- ./e2e/hostsite:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/post.html"]
interval: 2s
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
container_name: "remark42-e2e-mailpit"
ports:
- "127.0.0.1:8025:8025"
# without this `--wait` returns as soon as the container starts, and remark42 can be
# healthy before anything is listening on 1025
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 3s
retries: 30
# named volumes, not bind mounts, so `docker compose down -v` really does discard the
# databases and a run can start from an empty site
volumes:
remark42-e2e-var:
remark42-e2e-shortedit-var:
remark42-e2e-adminedit-var:
remark42-e2e-jwtheader-var:
remark42-e2e-noauth-var:
remark42-e2e-anonvote-var:
remark42-e2e-https-var: