* site: fetch latest version client-side instead of embedding at build time
The header version badge was filled in by site/src/data/github.js calling
the GitHub releases API at Eleventy build time and baking data[0].tag_name
into every page. This had three failure modes:
1. Layered cache: Buildx caches the yarn build layer; on a release-triggered
workflow nothing under ./site changes, so the cached HTML (with the
previous tag baked in) gets shipped. v1.16.0 went out and remark42.com
kept showing v1.15.0 until a separate site/ commit landed and naturally
invalidated the COPY layer.
2. Tag mismatch: the deploy pulls ghcr.io/umputun/remark42-site:master,
but release events build :v1.16.0 and :latest only. A cache-skip
workflow tweak wouldn't even reach the served image.
3. API propagation race: the workflow fires ~2s after release publish,
so even with cache disabled the API might still return the previous
tag from a stale read replica.
All three vanish if the version is fetched in the browser. GitHub serves
the /releases/latest response with Cache-Control: public, max-age=60 so
per-visitor cost is bounded; failures fall through silently and the
badge stays empty rather than wrong.
Changes:
- header.njk: replace {{ github.latestVersion }} with a
<span data-remark42-version></span> placeholder.
- inline.js: add a fetch of /releases/latest that fills any
[data-remark42-version] element on the page. fallback is no-op on any
network/parse failure.
- delete site/src/data/github.js (Eleventy data file is no longer used).
- drop node-fetch from devDependencies (was used only by github.js).
* site: address PR review on version badge fetch
- gate DOM update on DOMContentLoaded — inline.js is loaded sync in <head>,
so a cache-hit fetch can resolve before the placeholder span is parsed.
- hide placeholder span by default (`hidden`) so a failed/blocked fetch
doesn't leave a 0.5rem stray gap before the github icon.
- log fetch failures (rate limit, offline, blocked) instead of silently
swallowing — matches the prior behaviour of build-time github.js.
* site: cache latest version in sessionStorage with 1h TTL
avoids hitting the GitHub API on every page load — repeated navigations
within a tab read from sessionStorage instead. TTL caps stale display at
1h for very long-lived tabs. cleared on tab close, so each new session
fetches once and reuses the result throughout.
* site: address PR review on header & version fetch
Copilot review on the cache commit raised three points:
1. inline.js had a hard-coded `https://api.github.com/repos/umputun/remark42`
while the templates use `site.githubUrl`. Rename inline.js → inline.njk
so nunjucks evaluates it, add `githubApiUrl` to site.json, and template
the fetch URL from it. One place to update if the repo ever moves.
2. header.njk aria-label said "Remark42's GitHub Repository" but the link
target is `/releases`. Change to "{{ site.name }} releases on GitHub"
so screen readers describe the actual destination.
3. console.warn on fetch failure (kept after umputun's prior review noted
the trade-off): addressed in the PR description, no code change.
* site: actually template fetch URL via site.githubApiUrl
Copilot's second pass caught that 7697dcf3 added site.githubApiUrl,
renamed inline.js → inline.njk, and pointed head.njk at the .njk file
— but the fetch() call itself was never changed to use the template
variable. Build output looked correct because the literal hard-coded
URL happened to match what {{ site.githubApiUrl }} would expand to.
* site: normalise Nunjucks spacing in header.njk
{{ site.githubUrl}}/releases → {{ site.githubUrl }}/releases. Cosmetic
only; matches the spacing used everywhere else in the templates.
* fix(frontend): no_footer scrollbar regression introduced in v1.16.0
Two unrelated changes in v1.16.0 combined to surface a scrollbar in
no_footer=true mode:
1. c26f45e5 removed the deprecated `scrolling="no"` iframe attribute
on the grounds that "overflow is already hidden via CSS". That CSS
(`overflow: hidden` in createIframe styles) is on the iframe ELEMENT
in the parent page; it has no effect on the iframe DOCUMENT's own
scrollbars. The spec-correct replacement is `overflow: hidden` on
the iframe document's body — added here to global.css.
2. The negative `margin-bottom: -24px` on `.thread:last-child` was a
trick to tighten the gap to the footer (combined with the footer's
`margin-top: 48px` it collapsed to a 24px net gap). With no_footer
the negative margin had no positive-margin sibling to collapse
against and instead propagated up through .root, leaving body
~24px shorter than the visual content. The iframe height calc
(`body.offsetHeight + 12`) then sized the iframe below the visible
bottom of the last thread → scrollbar.
Replace the negative-margin trick with a straight `margin-top: 24px`
on `.copyright`. Same 24px visual gap when the footer is shown, no
propagation when it isn't. The mix={styles.thread} on Thread becomes
a dead reference and is dropped.
Closes#2073
* fix(frontend): drop dead Thread.mix prop after root.tsx removed its only caller
Both Copilot and umputun flagged this in PR review: after the parent
commit on this branch dropped `mix={styles.thread}` from root.tsx, the
`mix?: string` prop and the corresponding entry in the clsx() call in
thread.tsx are dead code — no caller passes it (the recursive Thread
render in thread.tsx:82 never did either). Remove the prop, the
destructure, and the clsx entry.
* fix: address parameter docs and --help text inconsistencies
Audit findings from comparing site/src/docs/configuration/parameters/
against the backend flag tags.
Docs (parameters/index.md):
- image.bolt.file default was `/var/pictures.db` (absolute, looks like
a system path); actual default is `./var/pictures.db` (relative,
under the working dir).
- notify.webhook.template default was shown as
`{"text": {{.Text | escapeJSONString}}}` — both the function name
doesn't exist and the unescaped pipe inside the table cell broke the
Description column count for that row. Real default is the literal
`{"text": "{{.Text}}"}`.
- "Custom OAuth2 integration currently supports only one custom
provider at a time" was a free-standing paragraph wedged between two
table rows. kramdown terminated the table on that paragraph and
restarted a new headerless table for the rest of the rows. Moved it
to its own subsection after the table so the table stays contiguous.
Backend --help text (server.go):
- allowed-hosts description ended with a stray double apostrophe in
`CSP 'frame-ancestors''` (typo).
- Deprecated auth.email.{port,passwd,user,tls} flag descriptions were
shuffled — port said "SMTP password", passwd said "SMTP port", user
said "enable TLS", tls said "SMTP TCP connection timeout". Fixed
each to match the flag it's actually describing. Docs already had
the correct descriptions for these deprecated flags.
* fix: webhook template flag default override masking safe fallback
Copilot flagged the audit's "real default" claim and was right. server.go:286
had default:"{\"text\": \"{{.Text}}\"}" — the literal, JSON-unsafe template
that produces invalid JSON if a comment contains a quote or newline. The
notify package (webhook.go:50) has a safer fallback:
if params.Template == "" {
params.Template = webhookDefaultTemplate
}
where webhookDefaultTemplate is {"text": {{.Text | escapeJSONString}}}. But
go-flags applies its default tag at parse time, so the field is never empty
when the user omits --notify.webhook.template, and the safer fallback never
runs.
Drop the unsafe default tag so the webhook package's escapeJSONString-based
default takes effect. Also:
- fix the --help description (was "webhook authentication template", but
it's a payload template, not an auth one; same for headers).
- update parameters/index.md to document the actual safe default
({{.Text | escapeJSONString}}); escape the cell's | as \| so kramdown
doesn't treat it as a column separator.
- typo: "bellow" -> "below" in the headers env-delim comment.
opening backtick had no closer in the Default column, so kramdown saw a
broken cell and stopped rendering the parameters table — every row from
smtp.login_auth onward (~40 rows) rendered as raw pipe-delimited text
instead of HTML table cells. closes#2074
replace the Docker artifact build with GoReleaser config and a tag release workflow. Keep local artifact builds snapshot-only and clean generated frontend embed files after release runs.
* 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.
readAndValidateImage caps the byte size of incoming images but the resize()
helper that follows still called image.Decode unconditionally, allocating
pixel memory proportional to the *declared* image dimensions. A ~100 KB
compressed PNG or GIF that declares 65535x65535 px forces image.Decode to
allocate ~17 GB of raster, OOMing the service on a single comment upload
(or on the proxy's CacheExternal path when caching a malicious upstream).
Hardening:
- maxImagePixels = 16 MP constant. Covers any realistic image (~4096x4096)
while bounding peak allocation.
- resize() now runs image.DecodeConfig first (cheap, no pixel allocation)
to read declared width/height before any full decode.
- Multiplication of width × height uses int64 to defeat 32-bit overflow
(GOARCH=386, 32-bit arm): on those targets, int(cfg.Width)*int(cfg.Height)
could wrap below maxImagePixels and bypass the cap. GIF's 16-bit logical
screen and JPEG's 16-bit SOF dimensions both reach this if int-multiplied.
- Bytes exceeding the cap, or non-image input that fails DecodeConfig,
return nil. prepareImage propagates the rejection as a clear error
instead of storing the malformed/oversized data verbatim.
- The no-resize-needed path returns the validated original bytes verbatim
so animated GIFs round-trip without being flattened to a single frame.
The DecodeConfig precheck applies even when MaxWidth/MaxHeight are 0
(resize disabled) — the dimension cap is unconditional defense-in-depth.
Two adjacent fixes surfaced by the new resize contract:
1. readAndValidateImage previously did `data[:512]` without a bounds check,
panicking on any body shorter than 512 bytes. Now bounded with min().
2. image/webp was listed as an allowed format but no WebP decoder was
registered, so DecodeConfig would refuse legitimate WebP uploads. Added
`_ "golang.org/x/image/webp"` (already in go.mod via x/image/draw) so
the registered decoders match the allowlist.
Tests:
- TestService_resizeRejectsDecompressionBomb builds a 14-byte GIF87a header
declaring 65535x65535 and asserts resize() refuses it both at the unit
level and through SaveWithID end-to-end (no store write).
- TestService_SaveWithIDShortPayload regression-tests the short-body panic.
- TestService_SaveWithIDWebP regression-tests WebP round-trip through
prepareImage with the new DecodeConfig requirement.
- TestService_resize subtests updated to assert non-image bytes are now
refused (previously the helper fell back to returning the raw bytes
verbatim, letting malformed content reach the store).
Mention the "Report a vulnerability" button (GitHub private vulnerability
reporting) alongside the existing email contact, now that private reporting
is enabled on the repository.
* Probe /auth/status from frontend to avoid 401 console noise on /user
GET /api/v1/user requires auth and returns 401 for anonymous visitors,
which the browser logs to console even when JS catches it. Probe
/auth/status first (always 200), then fetch /user only when logged in.
Stale auth cookies are cleared when status reports "not logged in" to
preserve the cleanup-on-probe behaviour previously triggered by /user 401.
Closes#1188.
* Don't clear auth cookies when /auth/status probe itself fails
A transient network/5xx on the /auth/status probe used to fall through
into the cookie-clear branch and silently log the user out on the next
page load. Distinguish "probe failed" (null) from explicit "not logged
in"; only the latter clears JWT/XSRF cookies. Lock the distinction with
a negative assertion in the probe-failure test.
Also align packages/api prettier config with apps/remark42 (trailingComma: 'es5')
so future edits don't sweep unrelated trailing commas into the diff.
* fix(auth): close OAuth open-redirect by wiring AllowedRedirectHosts
Bump go-pkgz/auth/v2 to master (v2.1.2-0.20260421203319-686683f19cf7)
which carries the `from` redirect validator from go-pkgz/auth#275.
The library default with a nil AllowedRedirectHosts is permissive
(preserves legacy behavior for existing consumers on a dep bump), so
just bumping the dep leaves remark42 vulnerable — a crafted
/auth/<provider>/login?from=https://evil.example.com/... still issues
the 307 to the attacker host after the user completes legitimate
OAuth. Verified end-to-end against a local dev-auth instance before
and after this commit.
Wire Opts.AllowedRedirectHosts in getAuthenticator to the operator's
existing --allowed-hosts config, stripping the CSP "self" sentinel
which is not a real hostname. RemarkURL's own host is always implicit
per the library contract, so a default single-site deployment gains
the protection with no config change. Multi-host embeds work as soon
as their embedding hosts are added to AllowedHosts (they already need
to be there for CSP frame-ancestors).
Refreshed vendor tree to match the new module version.
* chore(lint): suppress G703 false positives on image Save
CI's newer gosec flags os.MkdirAll/os.WriteFile in FileSystem.Save with
G703 because id flows in from the caller. id is validated at the HTTP
layer (safePictureSegment in rest_public.go) and dst is derived via
f.location — not a real traversal. Targeted //nolint with reason.
* fix(auth): normalise AllowedRedirectHosts entries + add unit test
Address Copilot review on PR #2049. The previous closure passed raw
s.AllowedHosts entries straight to the auth library, but --allowed-hosts
holds CSP frame-ancestors source expressions: scheme-prefixed values
(https://blog.example.com), entries with ports, and wildcards
(*.cdn.example.com) are all valid there but the auth library compares
against u.Hostname() and would silently drop them — breaking legitimate
redirects on multi-host deployments.
Extract getAllowedRedirectHosts that:
* trims whitespace, drops empty / 'self' / "self" / wildcard entries
* prepends https:// if scheme missing then url.Parse to extract Hostname
* logs a warning on parse failure rather than poisoning the allowlist
Wire the closure in getAuthenticator to call the helper.
Test_getAllowedRedirectHosts covers all the edge cases Copilot flagged
(scheme stripping, port handling, self spellings, wildcards, empty,
mixed real-world).
* fix(auth): preserve explicit port in AllowedRedirectHosts + clarify fs_store nolint
Address Copilot follow-up on PR #2049:
* getAllowedRedirectHosts stripped explicit ports via u.Hostname(), which
broadened the allowlist. The auth validator checks both Hostname() and
Host, so an entry like admin.example.com:8443 can and should be kept
host:port — allowing only that port, not any. Emit u.Host when
u.Port() != "", u.Hostname() otherwise. Updated tests.
* fs_store Save nolint rationale said "id validated at HTTP layer", but
Save is reached via image.Service.Save and SaveWithID (cache), neither
of which is HTTP validation. id is actually a server-generated hash in
both paths. Updated the comment.
Go 1.25's testing/synctest package (GA) provides a fake clock bubble
for deterministic goroutine and timer testing. Convert tests that
waited on real-time durations to use synctest, removing most wall-clock
time.Sleep workarounds.
Converted (11 tests, 9 files):
- notify/notify_test.go — all tests, replaced 17 time.Sleep(110ms) with synctest.Wait()
- store/service/service_test.go — VoteSameIPWithDuration, UserReplies, submitImages,
ResubmitStagingImages, deleteImagesOnCommentDelete
- store/image/{image,bolt_store}_test.go — Cleanup, Submit, SubmitDelay
- store/engine/bolt_test.go — FlagListBlocked
- providers/telegram_test.go — DispatchTelegramUpdates
- migrator/backup_test.go — TestBackup_Do
- _example/memory_store/accessor/data_test.go — FlagListBlocked
Simplifications along the way:
- notify/notify_mock.go: dropped the 10ms time.After delay and
ctx.Done select in MockDest — the artificial I/O simulation is
pointless and blocked synctest.Wait from draining the queue
- Removed three dead-code time.Sleep(1s) calls in EditCommentDurationFailed,
EditCommentAdmin, and Info tests: prepopulated comments from 2017
already exceed any EditDuration/ReadOnlyAge under real clock, making
the sleeps meaningless
- UserReplies: replaced the Eventually+Sleep+mutex polling with a
direct time.Sleep under fake clock
Skipped (incompatible with synctest):
- fs_store_test.go: relies on OS file mtime (real wall clock)
- rss_test.go: needs real wall-clock second boundary for pubDate
- admin/rest_private/rest_public tests: httptest network I/O
- cmd/server_test.go: real HTTP server startup polling
Notes on quirks encountered:
- synctest.Wait() does NOT advance fake time, contrary to what one
might expect. It only returns once all other bubble goroutines are
durably blocked. To advance the fake clock, the test goroutine must
itself call time.Sleep
- BoltDB keys the "last" bucket by comment.Timestamp nanosecond string.
Rapid b.Create calls under frozen fake time produce identical keys
and overwrite each other. TestService_UserReplies adds
time.Sleep(time.Nanosecond) between Creates to advance the clock
- Bolt image Cleanup uses strict age > ttl. Under fake time the
age-ttl delta is exactly zero at the boundary, so subtract 1ms from
the passed ttl to stay strictly under
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: umputun <535880+umputun@users.noreply.github.com>
Address review feedback on PR #2044.
safehttp.Transport():
* Clone http.DefaultTransport instead of building a bare &http.Transport{} so
Proxy, ForceAttemptHTTP2, MaxIdleConns, IdleConnTimeout, TLSHandshakeTimeout
and ExpectContinueTimeout are inherited (the bare struct loses them all).
Verified by new TestTransport_PreservesDefaultTransportSettings.
* TestTransport_AllowsPublic: bound the dial of TEST-NET-3 with a 100ms
context so the test does not depend on real-world routing of 203.0.113.0/24,
and drop the dead dialer var.
proxy/image.go:
* Document Image.Transport contract: nil installs safehttp.Transport (SSRF-safe);
caller-supplied transport is the caller's responsibility.
* Replace the misleading "SSRF mitigated by safehttp.Transport" nolint comments
with one that points at the documented contract above.
Address all golangci-lint v2.10.1 (CI's version) findings:
* Add http.MaxBytesReader hard cap to ParseMultipartForm sites in
rest_private.savePictureCtrl (32MB) and api/migrator (256MB) — fixes
G120 by bounding total request body before form parsing.
* Suppress G70x in CLI subcommands cmd/{backup,cleanup,import,remap}.go:
all four issue HTTP requests against operator-supplied RemarkURL/CLI
flags, never user input. Each suppression carries a one-line reason.
* Suppress G122 in image fs_store cleanup walk: staging directory tree
is server-only, no untrusted symlinks land there.
CI's golangci-lint v2.10.1 (newer rule set than my local 2.11.4) flags
four G70x cases the previous run missed. All are false positives:
backup.go and cleanup.go drive HTTP requests against the operator's own
RemarkURL from CLI flags (not user input); migrator.go removes a temp
file whose name was returned by os.CreateTemp (server-controlled). Add
targeted //nolint:gosec comments naming the reason at each site.
Commit aca0cff3 silenced the path-traversal, SSRF and XSS taint rules
project-wide as "false positives" while fixing image-proxy SSRF. With
the path-traversal and TitleExtractor SSRF gaps now closed, restore the
rules so future regressions get flagged. The four genuine false positives
that remain (image proxy http.NewRequest, QR png Write, two RSS XML
Writes) get individual //nolint:gosec comments naming the reason.
The image proxy got an ssrfSafeTransport in commit aca0cff3 that resolves
DNS first, blocks any IP in private/reserved CIDRs, then dials by IP to
defeat DNS rebinding. The TitleExtractor used to construct comments'
PostTitle from Locator.URL — a user-supplied field — was missed by that
fix and kept using http.DefaultTransport. The hostname allowlist there
checks the parsed URL host but never the IP it resolves to, so a domain
suffix-matching an allowed host (or 127.0.0.1 itself when AllowedHosts
is empty) reaches the metadata service or any other internal endpoint.
The same gosec rule (G704) was excluded globally in .golangci.yml as part
of aca0cff3, so this gap was not caught by the linter either.
Extract the transport into a new safehttp package so it lives in one
place and can be reused, then pass safehttp.Transport() into the
TitleExtractor's http.Client at construction (cmd/server.go). The image
proxy switches to safehttp.Transport() too — same behaviour, no longer
duplicated.
Reproduction in title_test.go uses the production-style client to hit
an httptest.Server (always 127.0.0.1) and asserts the dialer refuses
even though "127.0.0.1" is in the allowed-domains list. A control case
shows the same setup without safehttp.Transport returns the page —
making the original vulnerability explicit.
Address PR #2045 review (umputun):
* The //nolint:gosec on telegramQrCtrl's w.Write(png) was byte-identical
to the same line in #2044 (gosec-rule restoration). Drop it here so
the two PRs do not conflict; #2044 owns it.
* `seg == ".."` in safePictureSegment was already covered by the
strings.Contains(seg, "..") check two lines down — trim and add an
inline comment so the cover-by-superset is explicit.
Address PR #2045 review feedback (Copilot #2045-1). The previous
safePictureSegment allowed CR/LF/TAB through, so a request such as
GET /api/v1/picture/dev%0Auser/abc.png would inject literal newlines
into the access log line ("GET - /api/v1/picture/dev\nuser/abc.png ...")
— a log-forgery primitive against any operator parsing those logs.
Reject any unicode.IsControl rune in either segment (NUL was already
caught via strings.ContainsAny). New TestRest_LoadPictureRejectsControlCharsInSegment
covers LF, CR, TAB, NUL across both segments.
The unauthenticated GET /api/v1/picture/{user}/{id} handler concatenated the
two URL params verbatim into a filesystem path via path.Join, so a request
like /api/v1/picture/../remark.db resolved to <base>/../remark.db, escaping
the image directory. With Partitions=0 (a documented option) this is a
direct arbitrary-file read; with the default Partitions=100 the constructed
path lands in a CRC-derived subdirectory but the server still leaks the
internal filesystem path back to the unauthenticated caller via the JSON
error body — confirmed against demo.remark42.com (master-80c12a3) which
returned `stat /var/folders/.../staging/.../remark.db` for `..` requests.
Validate both URL segments via safePictureSegment (no traversal markers,
no path separators, no NULs) at the handler entry, and replace the raw
storage error with a generic "image not found" response. The original
error is logged for operators.
Reproduction test asserts that ../remark.db, foo/..%2Fremark.db and
%2E%2E/remark.db all return 400 with no internal path leaked.
The store tests stored timestamps with time.Local in their fixtures and
asserted equality against returned values that the engine round-trips
through UTC. assert.Equal compares zone identity, so on UTC machines
(CI, most cloud envs) Local==UTC and the tests passed; on a developer
machine in any other timezone (here BST, UTC+1) TestService_Put,
TestService_List, TestBoltDB_InfoPost, TestBoltDB_InfoList and several
others would fail with same wall-clock numbers but mismatched zones.
Replace time.Local with time.UTC across store/comment_test.go,
store/formatter_test.go, store/service/service_test.go,
store/engine/bolt_test.go, store/engine/engine_test.go. Production code
is untouched.
matchSiteID guarded most authenticated and admin routes with
`if siteID != "" && user.SiteID != siteID`. Dropping the ?site= query
parameter made the check no-op and any authenticated user passed the
middleware. Downstream handlers fell back to reading site from the JSON
body or just used the empty string, so on email/telegram subscribe
endpoints (which read site from body) a user authenticated to siteA
could perform actions targeting siteB without the cross-site guard
ever firing.
Require ?site= to be present and to match user.SiteID. Body-only site
flows are still supported provided the URL also carries the matching
?site= — both must agree, which removes the bypass and keeps the
declared site visible to the middleware.
Reproduction TestRest_matchSiteID enumerates four cases (matching,
mismatched, missing, empty). Existing test calls that relied on the
implicit pass had to add ?site=remark42 to the URL: the addComment
helper now derives the param from c.Locator.SiteID, picture upload
URL gets the param explicitly, and the email/telegram subscribe table
adds it to every endpoint. The negative cases that previously asserted
StatusBadRequest from the handler now correctly assert StatusForbidden
from the middleware.
* fix(embed): set color-scheme on iframe to fix Firefox dark mode
Firefox renders a white background in dark mode when color-scheme is 'none' on the iframe. Set color-scheme to match the active theme on both the outer iframe element and the inner document root, so Firefox uses the correct rendering mode from the start and on theme changes.
* fix(embed): default iframe color-scheme to light when no theme set
Changes the fallback from 'light dark' to 'light' to match the inner document's default behavior, which always defaults to light when no theme is specified.
The edit textarea was running `data.orig` through the browser's HTML
parser via a detached `<span>.innerHTML` to "decode entities", which
turned user-typed `<`/`>` into real `<`/`>`. On save, blackfriday
then saw a real `<script>` tag, bluemonday stripped it, and the comment
body collapsed to an empty string.
The decode block predates commit 243c835 (2022) which stopped the
backend from sanitising `orig` with bluemonday. Before 243c835, orig
came back HTML-escaped from the API and the frontend compensated.
After 243c835 the backend stores and returns orig byte-for-byte, but
the frontend decode was never removed — so it has been silently
corrupting user input containing entities for ~3.5 years.
The backend contract is clear: `orig` is the raw user input, never
rendered as HTML. The frontend should echo it back into the textarea
unchanged. This change removes the decode and adds 45 table-driven
regression tests covering entity round-trips, unicode edge cases,
and markdown constructs.
Bump Go dependencies in both backend/ and backend/_example/memory_store.
Notable updates:
- github.com/go-pkgz/lgr v0.12.1 -> v0.12.3
- github.com/klauspost/compress v1.18.2 -> v1.18.5
- github.com/PuerkitoBio/goquery v1.11.0 -> v1.12.0
- github.com/montanaflynn/stats v0.7.1 -> v0.9.0
- github.com/redis/go-redis/v9 v9.17.2 -> v9.18.0
- github.com/slack-go/slack v0.17.3 -> v0.21.1
- go.mongodb.org/mongo-driver v1.17.6 -> v1.17.9
- golang.org/x/crypto v0.48.0 -> v0.50.0
- golang.org/x/net v0.49.0 -> v0.53.0
- golang.org/x/image v0.36.0 -> v0.39.0
- golang.org/x/sys v0.41.0 -> v0.43.0
- golang.org/x/{oauth2,sync,text} minor bumps
Key markdown/sanitisation libs (bluemonday v1.0.27,
alecthomas/chroma/v2 v2.23.1, russross/blackfriday/v2 v2.1.0,
Depado/bfchroma/v2 v2.0.0) are already at the latest available
versions and were not bumped.
Verified the Chroma span-class allowlist regex in
backend/app/store/comment.go:128-131 is still fully in sync with
chroma/v2 types.go StandardTypes map (86 classes, byte-equal after
sorting). The inline comment references commit c263f6f which is
stale (Chroma is at v2 now), but the class list content is current.
Ran `go mod tidy` + `go mod vendor` + full race test suite on both
modules. All green. Added a reminder in CLAUDE.md that updating
backend/ Go modules also requires `go mod tidy` in
backend/_example/memory_store since the example module uses a
local replace directive and inherits indirect deps from the main
module.
Consolidate legacy BEM CSS files into CSS Modules for 4 components:
- dropdown/__item: 1 CSS file → dropdown-item.module.css
- list-comments: 1 CSS file → list-comments.module.css (removed unused
comments-list class that had no CSS rules)
- comment-form/__subscribe-by-rss: 1 CSS file → subscribe-by-rss.module.css,
removed dead titleClass prop and dead __rss-link directory
- settings: 10 CSS files → settings.module.css, removed dead
.settings__blocked-users-username CSS rule
Built artefact comparison (master vs branch):
- 83 of 89 files in /srv/web/ are byte-identical (all locale bundles,
SVGs, HTML pages unchanged)
- 6 files differ: remark.css/js/mjs and last-comments.css/js/mjs
- CSS changes are class name hash shifts (e.g. G_A → H_A) caused by
webpack's module ordering, plus 3 new var() fallback values added
by the CSS modules build; all property:value pairs are preserved
- JS changes are minified variable name shifts (O ↔ A, I ↔ L) from
changed import order; no logic changes
- Visual comparison (pixel-by-pixel screenshots of both light and dark
themes on the demo page) shows 0 different pixels
- Bundle sizes: remark.css -626 bytes, remark.js -512 bytes,
last-comments.css -16 bytes (dead CSS removed)
* frontend: remove deprecated iframe attrs and non-standard CSS
Three separate cleanups:
1. remove deprecated HTML attributes from iframe creation (create-iframe.ts)
- frameborder="0": deprecated since HTML5; border is already set to none via CSS
- allowtransparency="true": non-standard Microsoft attribute never in any spec;
transparency is handled by body { background: transparent } in CSS instead
- scrolling="no": deprecated since HTML5; overflow is already hidden via CSS
- horizontalscrolling/verticalscrolling: non-standard IE-era attributes with
no effect in modern browsers; remove without replacement
2. replace allowtransparency with explicit CSS (global.css)
- add background: transparent to body; this is the spec-correct way to make
an iframe document transparent, as documented by MDN
3. drop -moz-touch-enabled media query prefix (5 comment CSS files)
- -moz-touch-enabled was a Firefox-only non-standard media feature removed
in Firefox 58 (2018); pointer: coarse is the standard equivalent and was
already present as the second condition in every query, so removing the
dead -moz prefix reduces the media query to just (pointer: coarse)
note: colorScheme: 'none' in create-iframe.ts is intentionally left unchanged;
it is tracked by #1430 and requires a broader color-scheme implementation
* frontend: fix CSS bugs and replace deprecated properties
Bugs fixed:
- comment-votes.module.css: add missing comma between transition values;
without it the shorthand was invalid and colour transitions on vote
buttons were silently ignored
- icon-button.module.css: fix "transfrom" typo (should be "transform");
the misspelling made the transition declaration a no-op, so the hover
scale animation jumped instantly instead of easing
- auth.module.css: remove doubly-nested rgb(rgb(var(…))) call; the outer
rgb() rejected the inner rgb() result, so the .title element's colour
fell back to inherited instead of the intended --secondary-text-color
Deprecated properties replaced:
- comment-form__markdown-toolbar.css: replace deprecated clip: rect()
with clip-path: inset(50%); clip was deprecated in CSS Masking Level 1
- raw-content.css: replace word-wrap with overflow-wrap; word-wrap was
renamed in CSS Text Level 3, all current browsers support overflow-wrap
- global.css: remove redundant literal-colour fallback lines before
var() declarations in .preloader and .preloader_view_iframe; the var()
calls already have inline fallback values (e.g. var(--color6, #fff)),
making the preceding duplicate property and its stylelint-disable
comment unnecessary since IE11 EOL
* move border:none from inline style to widget__comments-frame class
Apply go fix ./... analysers (Go 1.26) across backend and examples:
- interface{} → any (type alias, no behaviour change)
- for i := 0; i < N; i++ → for range N / for i := range N
- slices.Contains / slices.ContainsFunc replacing manual loops
- strings.SplitSeq replacing strings.Split in range (avoids allocation)
- strings.CutPrefix replacing HasPrefix+TrimPrefix
- min() replacing manual if/else
- fmt.Appendf replacing []byte(fmt.Sprintf(...))
- strings.Builder replacing string += concatenation
- wg.Go(func(){}) replacing wg.Add(1)/go/wg.Done() pattern
- removed redundant ii := i loop variable copies (unnecessary since Go 1.22)
omitempty on struct-typed JSON fields: go fix removed omitempty from
struct-typed fields (time.Time, PostInfo, UserDetailEntry) because
encoding/json's omitempty never applied to struct types — it was always
a no-op. Kept as bare tags (no omitzero replacement) to preserve the
existing serialisation behaviour.
Add skipLibCheck to skip type checking of .d.ts files in node_modules,
matching the setting already used by the main remark42 app. Fixes
@types/eslint-scope vs @types/eslint type incompatibility.
Replace manual actions/cache steps with built-in setup-node cache support.
Add cache: pnpm and cache-dependency-path to all setup-node steps in both
ci-frontend.yml and ci-frontend-api.yml. Move pnpm install before setup-node
as required for pnpm caching to work.
Replace strings.Split(RemoteAddr, ":") with net.SplitHostPort for correct
IPv6 address extraction in vote deduplication and comment IP tracking.
Harden image proxy: add SSRF-safe transport blocking private/reserved IPs
at connection time with DNS rebinding protection, sanitize error messages
to prevent information leakage, add response size limit via io.LimitReader.
Fix shadowed error variables in BlockedUsers, SetTitle, and Delete methods.
Exclude gosec taint analysis false positives at linter config level.
Clarify that any content placed inside the `<div id="remark42">` is
automatically removed once the iframe signals it has initialised.
Update all code examples across getting-started, frontend config, and
Astro/Gatsby integration guides to use "Comments loading..." as the
placeholder so the feature is visible by default.
Deploy jobs only curl an external updater URL and need no GitHub API
access. Without an explicit permissions block they inherit the workflow
default, which may include contents:write, packages:write, etc.
Setting permissions to {} limits the blast radius if a job is
compromised.