Skip to content

Fix endpoint XSS & rate-limit spoofing, add PLATFORM config#32

Merged
botre merged 4 commits into
masterfrom
fix/endpoint-xss-and-ratelimit-spoofing
May 19, 2026
Merged

Fix endpoint XSS & rate-limit spoofing, add PLATFORM config#32
botre merged 4 commits into
masterfrom
fix/endpoint-xss-and-ratelimit-spoofing

Conversation

@botre
Copy link
Copy Markdown
Collaborator

@botre botre commented May 19, 2026

Summary

Security fixes from an app-side review, plus a PLATFORM env var that makes httphq portable across hosting providers. Final commit is a repo-wide Prettier pass (formatting only).

P1 — Reflected XSS via the endpoint path param (high)

endpoint.html rendered {{.EndpointURL}} into an Alpine @click expression. Go's html/template only treats on* attributes as JS contexts, so @click was merely HTML-escaped — a ' supplied as %27 in the path survived, broke out of the copy('...') string, and Alpine evaluated the rest via the Function constructor (permitted by the CSP's 'unsafe-eval'). Any single-segment path renders the endpoint page, so the attack was a crafted link, e.g. /x%27%29%3Balert(1)%3B%28%27.

  • The copy button now reads the URL from the DOM (x-ref="endpointUrl") instead of interpolating a template value into a JS expression.
  • validEndpointID() (regex ^[a-z0-9]+(-[a-z0-9]+)*$, max 64 chars) rejects any non-haikunator :endpoint param with a 404, applied across GET /:endpoint, Use /to/:endpoint, GET /ws/:endpoint, and the /api/endpoints/:endpoint/... routes. The charset excludes ' " < > ( ) ; %, independently killing the XSS class and keeping junk out of the DB/logs.

P3 — Rate-limit bypass + spoofed client IP (medium)

With TrustProxy: true, both resolveClientIP and the limiter's key read a client-supplied X-Forwarded-For (Cloudflare appends to it rather than stripping it). Rotating XFF handed out a fresh rate-limit bucket per request and poisoned the stored/logged client IP. The limiter now keys on the resolved client IP; resolveClientIP is driven by the PLATFORM config below.

PLATFORM env var

httphq runs behind different providers depending on where it's self-hosted, each exposing the real client IP under its own header. PLATFORM lets the operator declare their provider; httphq resolves the client-IP strategy from it.

PLATFORM Client IP source
unset / direct TCP connection peer (no proxy)
cloudflare CF-Connecting-IP
fly Fly-Client-IP
heroku X-Forwarded-For (leftmost)
render X-Forwarded-For (leftmost)
proxy X-Forwarded-For (leftmost) — generic proxy
  • Declaring the platform also strips its vendor headers (e.g. Cloudflare's Cf-*) from captured requests, so users inspect their own traffic without infrastructure noise. Cdn-Loop (a standard CDN header) joins the generic omittedHeaders list.
  • TrustProxy is now conditional on a platform being set — a directly exposed deployment no longer trusts forwarded headers.
  • An unknown PLATFORM fails safe to direct. README documents PLATFORM, APPLICATION_ENV, and LOG_LEVEL.

Trust model

Setting PLATFORM trusts that platform's client-IP header unconditionally — the operator must ensure inbound traffic can't bypass the platform, or a client could spoof its IP. Documented in the README.

Testing

  • go build ./src, go vet ./src, go test ./... pass.
  • validEndpointID, resolvePlatform, and omitHeader covered by focused tests during development (XSS payload, platform mapping, header stripping); the repo keeps no main-package tests so they were not committed.
  • Live curl / Playwright checks were not run locally due to a port-8080 conflict with an unrelated container; e2e uses haikunator-format IDs so endpoint validation won't reject it.

botre added 4 commits May 19, 2026 16:55
P1: The endpoint page rendered {{.EndpointURL}} into an Alpine @click
expression. html/template only HTML-escapes @click (not a JS context),
so a quote supplied via %27 in the path broke out of the copy('...')
string and Alpine evaluated the rest via Function() — permitted by the
CSP's 'unsafe-eval'. The copy button now reads the URL from the DOM
(x-ref) instead, and validEndpointID() rejects any :endpoint param that
isn't haikunator-shaped (lowercase words/digits, hyphen-joined, <=64)
with a 404 across all endpoint routes.

P3: Behind Cloudflare, a client-supplied X-Forwarded-For is appended to,
not stripped, so resolveClientIP and the limiter's c.IP() key both read
an attacker-controlled value — rotating XFF defeated the rate limiter
and poisoned the stored client IP. resolveClientIP now prefers the
unforgeable CF-Connecting-IP header, and the limiter buckets on the
resolved IP via an explicit KeyGenerator.
…ping

httphq runs behind different providers depending on where it's
self-hosted, each exposing the real client IP under its own header. The
PLATFORM env var lets the operator declare their provider (cloudflare,
fly, heroku, render, proxy, or direct) and httphq resolves the right
client-IP strategy from it — replacing the Cloudflare-hardcoded chain.

Declaring the platform also strips that provider's vendor headers
(e.g. Cloudflare's Cf-*) from captured requests, so users inspect their
own traffic without infrastructure noise. Cdn-Loop (RFC 8586, not
vendor specific) joins the generic omittedHeaders list.

TrustProxy is now conditional on a platform being set, so a directly
exposed deployment doesn't trust forwarded headers. An unknown PLATFORM
fails safe to direct. README documents PLATFORM, APPLICATION_ENV and
LOG_LEVEL.
No functional changes — whitespace and wrapping only.
@botre botre changed the title Fix endpoint-ID XSS and rate-limit IP spoofing Fix endpoint XSS & rate-limit spoofing, add PLATFORM config May 19, 2026
@botre botre merged commit 0067f4f into master May 19, 2026
2 checks passed
@botre botre deleted the fix/endpoint-xss-and-ratelimit-spoofing branch May 19, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant