* fix(security): reject non-image content-types in image proxy and /picture/ to prevent stored XSS
The /api/v1/img proxy and /api/v1/picture/{user}/{id} endpoints emitted
http.DetectContentType on the served bytes as the response Content-Type. A
controlled upstream serving Content-Type: image/png with an HTML body passed
the upstream check (only the response header was inspected, not the body),
and the body bytes then sniffed back to text/html — so the proxy served the
attacker's HTML from the remark42 origin. Browsers honoured the declared
text/html and executed the response as a document with access to cookies and
CSRF tokens. Affected from v1.6.0 (April 2020) through v1.15.0; verified live
via published docker images.
Layered defense applied to both handlers:
- rest.SafeImgContentType (in backend/app/rest/) validates sniffed content
against a strict allowlist: image/png, image/jpeg, image/gif, image/webp,
image/bmp, image/x-icon. Anything else (HTML, XML, SVG, plain text,
octet-stream, or any future image type the stdlib sniffer may learn) is
rejected with no body echo. SVG is implicitly excluded — it sniffs as
text/xml or text/plain, never image/svg+xml, and SVG can execute scripts
when navigated to top-level. The previous octet-stream → image/* fallback
is gone.
- Per-endpoint Content-Security-Policy override sets
"default-src 'none'; sandbox; frame-ancestors 'none'" on every response
(success, 304, or error). Sandbox neuters scripts even if Content-Type
ever regresses. The same policy is also applied to all /api/v1/* via
apiCSPMiddleware as defense-in-depth.
- Content-Disposition: inline; filename="image" frames the response as a
file rather than a renderable document.
- /picture/ rejection paths set Cache-Control: no-store so 4xx responses
are never cached.
The defense headers and the strict ETag matcher are extracted as
rest.SetImageDefenseHeaders and rest.EtagMatches in the shared rest package
(consumed by both proxy/image and api/rest_public — no package cycle).
The /api/v1/img path additionally bumps the ETag to a versioned `"v2:..."`
so revalidating clients (top-level navigation, Ctrl+R, intermediaries) get
a fresh 200 instead of a 304 against poisoned pre-fix cached HTML.
DELIBERATE TRADEOFF: Cache-Control on /api/v1/img success responses remains
max-age=2592000 (30 days), unchanged from before. An aggressive "force
revalidate on every reuse" policy was prototyped during review but reverted
because the perf cost (a server round-trip on every image view, even with
304 saving the body bytes) outweighed the corner-case mitigation. The
realistic exposure of cache carryover is narrow: cache carryover only
affects users who navigated top-level to an attacker URL pre-fix and still
have it in their local cache — the normal <img> embed path cached text/html
but never executed it. Local browser caches that hold pre-fix bytes
continue to serve them until their 30-day TTL expires or are evicted under
memory pressure. The ETag bump reaches all clients that DO revalidate
during the cached lifetime (Ctrl+R, intermediaries, post-expiry use); for
the rest, exposure self-limits via cache expiry. Operators running a
CDN/edge cache in front of remark42 should purge /api/v1/img after deploy.
The /api/v1/img handler short-circuits on a matching current-version
If-None-Match before any store Load or upstream fetch, returning a bodyless
304 with the defense headers set. Safe because the 304 carries no body and
the client's cached bytes came from a prior validated 200; an attacker
fabricating an etag value can only short-circuit fetches for URLs they
themselves crafted. This avoids upstream DoS amplification when clients
revalidate on hot comment pages.
The /api/v1/img route was moved from the "open routes" group (which uses
middleware.NoCache, stripping If-None-Match from incoming requests) to the
"open routes, cached" group alongside /picture/ and /qr/telegram so the
304 revalidation path is no longer broken upstream of the handler.
The /picture/{user}/{id} endpoint does not need the v2 etag prefix. Upload
validates input format via readAndValidateImage and the serve path
re-validates the stored bytes via rest.SafeImgContentType. Bytes within
the resize dimension limits are preserved verbatim, so the browser defense
relies on the response headers (validated Content-Type + nosniff + strict
CSP + Content-Disposition: inline), not on byte normalization.
Global CSP: font-src data: → font-src 'none'. Audit confirmed no @font-face,
no base64 fonts, no icon-font library in the bundle. Drops an unnecessary
attack surface; no behavioural change.
Tests: TestImage_ContentTypeHandling table-tests a real PNG and attack
shapes (HTML claimed as image/png, image/jpeg, image/gif, image/svg+xml,
image/webp; svg with onload; html fragment; polyglot PNG+HTML), proving
the defense holds across arbitrary upstream Content-Type variation.
Polyglot case is intentionally served as image/png — the browser cannot
execute the trailing HTML when the response type is image/png with nosniff.
TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch with
attacker bytes preloaded into the store. TestImage_PerRequestRevalidation
alternates upstream PNG/HTML across four proxy calls to prove no trust
accumulates between requests. TestImage_RoutesUsingCachedImage asserts
cache-poisoning is caught at serve time. TestImage_EtagVersioned asserts
the v2 prefix invalidates pre-fix etags AND that the revalidation 304
triggers no store Load. TestImage_RevalidationSkipsIO proves the
short-circuit works even with no upstream reachable. TestSafeImgContentType
covers the allowlist directly. TestRest_LoadPictureDefenseHeaders and
TestRest_LoadPictureRejectsNonImage exercise the /picture/ endpoint.
TestRest_apiCSP covers the strict CSP middleware on JSON API + RSS routes;
TestRest_securityHeaders confirms /web/ HTML pages keep the global CSP.
Verified end-to-end against the dev docker image: the original demo URL
(arbitrary HTML claimed as image/png) now returns 415 application/json with
CSP/nosniff/Content-Disposition set, no XSS in the browser.
* fix(security): set Cache-Control: no-store on image-proxy error paths, sync stale route comment
Addresses two review comments on #2067:
1. Cache-Control: max-age=2592000 and Etag were set before the
load/download/validation block, so 404/400/415 error responses inherited
the 30-day cache TTL and the versioned etag — a transient failure (or an
intentionally triggered 415) would be pinned in browser/intermediary
caches for that TTL, keeping users locked out even after the underlying
cause was resolved. Now: etag is computed but not set as a header until
after validation succeeds; error paths route through sendImageProxyError
which sets Cache-Control: no-store and never sets Etag. The 304
short-circuit still sets both because that path serves the same validated
content the client already has cached.
2. The comment at rest.go:282 still described the prototyped
no-cache/must-revalidate Cache-Control policy that was reverted before
the PR landed. Updated to match the actual 30-day max-age behavior.
Tests: TestImage_ContentTypeHandling now asserts reject paths carry
Cache-Control: no-store and have no Etag header, and accept paths carry
the max-age=2592000 + v2: etag.
title
| title |
|---|
| Backend Development Guidelines |
You can use a fully functional local version to develop and test both frontend and backend. It requires at least 2GB RAM or swap enabled.
To bring it up, run:
cp compose-dev-backend.yml compose-private.yml
# now, edit / debug `compose-private.yml` to your heart's content
# build and run
docker compose -f compose-private.yml up --build
It starts Remark42 on 127.0.0.1:8080 and adds local OAuth2 provider "Dev". To access the UI demo page go to http://127.0.0.1:8080/web/. By default, you would be logged in as dev_user, defined as admin. You can tweak any of the supported parameters in corresponded yml file.
::: note 🚨
Please use 127.0.0.1 and not localhost to access the server; otherwise, CORS will prevent your browser from authentication to work correctly. You could alter the address for dev auth with the REMARK_URL environment variable.
:::
Backend Docker Compose config (compose-dev-backend.yml) by default skips running frontend related tests. Frontend Docker Compose config (compose-dev-frontend.yml) by default skips running backend related tests and sets NODE_ENV=development for frontend build.
Backend development
With Docker
Run tests in your IDE, and re-run make rundev each time you want to see how your code changes behave to test them at http://127.0.0.1:8080/web/.
Without Docker
You have to install the latest stable go toolchain to run the backend locally.
In order to have working Remark42 installation you need once to copy frontend static files to ./backend/web directory from master docker image, as it is expected to be where application compiles:
# frontend files
docker pull ghcr.io/umputun/remark42:master
docker create -ti --name remark42files ghcr.io/umputun/remark42:master sh
docker cp remark42files:/srv/web/ ./backend/app/cmd/
docker rm -f remark42files
# fix frontend files to point to the right URL
## Mac version
find -E ./backend/app/cmd/web -regex '.*\.(html|js|mjs)$' -print -exec sed -i '' "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
## Linux version
find ./backend/app/cmd/web -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
To run backend - cd backend; go run app/main.go server --dbg --secret=12345 --url=http://127.0.0.1:8080 --admin-passwd=password --site=remark. It stars backend service with embedded bolt store on port 8080 with basic auth, allowing to authenticate and run requests directly, like this:
HTTP http://admin:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080
Technical Details
Data stored in boltdb (embedded key/value database) files under STORE_BOLT_PATH. Each site is stored in a separate boltdb file.
To migrate/move Remark42 to another host, these boltdb files must be transferred. Additionally, the avatars directory AVATAR_FS_PATH (default ./var/avatars) and images directory IMAGE_FS_PATH (default ./var/pictures) must be transferred. As an alternative to storing avatars and images in the file system, boltdb can be used by setting AVATAR_TYPE or IMAGE_TYPE to bolt. Files in the IMAGE_FS_STAGING directory can be safely ignored because they are moved to the image store between app restarts.
The automatic backup process runs every 24h and exports all content in JSON-like format to backup-remark-YYYYMMDD.gz.
Authentication implemented with go-pkgz/auth stored in a cookie. It uses HttpOnly, secure cookies.
All heavy REST calls cached internally in LRU cache limited by CACHE_MAX_ITEMS and CACHE_MAX_SIZE with go-pkgz/rest.
User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec).
Request timeout set to 60sec.
Admin authentication (--admin-password set) allows to hit Remark42 API without social login and admin privileges. Adds basic-auth for username: admin, password: ${ADMIN_PASSWD}. Enable it only for the initial comment import or for manual backups. Do not leave the server running with admin password set if you don't have an intention to keep creating backups manually!
User can vote for the comment multiple times but only to change the vote. Double voting is not allowed.
User can edit comments in 5 mins (configurable) window after creation.
User ID hashed and prefixed by OAuth provider name to avoid collisions and potential abuse.
All avatars resized and cached locally to prevent rate limiters from OAuth providers, part of go-pkgz/auth functionality.
Images served over HTTP can be proxied to HTTPS (IMAGE_PROXY_HTTP2HTTPS=true) to prevent mixed HTTP/HTTPS.
All images can be proxied and saved locally (IMAGE_PROXY_CACHE_EXTERNAL=true) instead of serving from the original location. Beware, images that are posted with this parameter enabled will be served from proxy even after it is disabled.
Docker build uses publicly available base images.