diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d099d4..8435483 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: "1.25" cache: false - run: go vet ./... @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: "1.25" cache: false - run: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/httphq ./src diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1065f3f..d3e9349 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: "1.25" cache: false - run: go test ./... e2e: @@ -28,11 +28,11 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: "1.25" cache: false - uses: actions/setup-node@v6 with: - node-version: '22' + node-version: "22" - name: Build server run: CGO_ENABLED=0 go build -o ./bin/httphq ./src working-directory: . diff --git a/README.md b/README.md index b4ef6df..a9b3cc4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,45 @@ [Scripts](docs/scripts.md) +## Configuration + +httphq is configured entirely through environment variables. + +| Variable | Default | Description | +| ----------------- | ------------- | ----------------------------------------------------------------------------------------------------------- | +| `APPLICATION_ENV` | `development` | Set to `production` to bind all interfaces, raise the rate limit, and default logging to `info`. | +| `LOG_LEVEL` | env-dependent | Overrides the log level: `debug`, `info`, `warn`, or `error`. | +| `PLATFORM` | `direct` | The hosting platform in front of httphq — selects which header the real client IP is read from (see below). | + +### `PLATFORM` + +httphq derives the client IP (used for rate limiting and shown on captured +requests) from the header your hosting platform sets. Declare your platform +and httphq picks the right strategy: + +| `PLATFORM` | Client IP source | +| ---------------- | ----------------------------------------------------------------------- | +| unset / `direct` | The TCP connection peer (no proxy). | +| `cloudflare` | `CF-Connecting-IP` header. | +| `fly` | `Fly-Client-IP` header. | +| `heroku` | `X-Forwarded-For` (leftmost). | +| `render` | `X-Forwarded-For` (leftmost). | +| `proxy` | `X-Forwarded-For` (leftmost) — generic nginx / Traefik / load balancer. | + +An unrecognised value falls back to `direct`. + +Declaring the platform also strips its vendor-specific headers (e.g. +Cloudflare's `Cf-*`) from captured requests, so users inspect their own traffic +without the noise of whatever provider sits in front of httphq. + +> **Security note.** Setting `PLATFORM` trusts that platform's client-IP +> header unconditionally — httphq cannot tell a real platform header from one +> a client forged. You must ensure inbound traffic cannot reach httphq +> bypassing the platform (e.g. lock your origin to the platform's IP ranges), +> or a client can spoof its IP and evade rate limiting. Conversely, if you run +> behind a proxy but leave `PLATFORM` unset, every request appears to come +> from the proxy and rate limiting becomes global. + ## Logging httphq logs to stdout as structured JSON via the standard library's `log/slog` — one JSON object per line, with no log files or shipping built in, so any collector can pick the logs up. Field names follow OpenTelemetry conventions (`service.name`, `http.request.method`, `url.path`, `http.response.status_code`, ...). Every request gets a correlation `request_id` (a valid inbound `X-Request-Id` is reused, otherwise one is minted) that is echoed back on the response header and stamped onto every log line emitted while handling that request. Each request produces one access-log line; headers and bodies are never logged, paths are logged without their query string, and a denylist masks sensitive keys as a backstop. The level defaults to `info` in production (`debug` elsewhere) and is overridable with `LOG_LEVEL`; Kubernetes probe traffic to `/api/health` logs at `debug` so it stays out of production logs. diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 006e1b3..365fb1e 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -1,8 +1,4 @@ -import { - test, - expect, - type APIRequestContext, -} from "@playwright/test"; +import { test, expect, type APIRequestContext } from "@playwright/test"; const randomId = () => Math.random().toString(36).slice(2, 7); @@ -40,13 +36,11 @@ test.describe("Endpoint screen", () => { test("copy-url button writes the URL to the clipboard", async ({ page }) => { await page.locator('[data-test="copy-url"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); expect(clipboard).toBe(endpointUrl); - await expect( - page.locator('[data-test="copy-url-label"]'), - ).toContainText("Copied!"); + await expect(page.locator('[data-test="copy-url-label"]')).toContainText( + "Copied!", + ); }); test("disclaimer about 4-hour retention is visible", async ({ page }) => { @@ -60,15 +54,11 @@ test.describe("Endpoint screen", () => { page, }) => { await page.locator('[data-test="send-toggle"]').click(); - await page - .locator('[data-test="send-method"]') - .selectOption("PUT"); + await page.locator('[data-test="send-method"]').selectOption("PUT"); await page .locator('[data-test="send-headers"]') .fill("X-Source: panel\nContent-Type: application/json"); - await page - .locator('[data-test="send-body"]') - .fill('{"hello":"panel"}'); + await page.locator('[data-test="send-body"]').fill('{"hello":"panel"}'); await page.locator('[data-test="send-submit"]').click(); const card = page.locator('[data-test="request"]').first(); @@ -123,9 +113,9 @@ test.describe("Endpoint screen", () => { await expect(details).toContainText("127.0.0.1"); await expect(details).toContainText(endpointPath); await expect(card).toContainText("POST"); - await expect( - card.locator('[data-test="request-headers"]'), - ).toContainText("Content-Type"); + await expect(card.locator('[data-test="request-headers"]')).toContainText( + "Content-Type", + ); await expect(card.locator('[data-test="request-body"]')).toContainText( "Hello, World!", ); @@ -145,9 +135,7 @@ test.describe("Endpoint screen", () => { expect(text).toContain('"hello": "world"'); expect(text).toContain("\n "); // Highlight.js wraps tokens in elements. - const tokenCount = await body - .locator("pre span.hljs-string") - .count(); + const tokenCount = await body.locator("pre span.hljs-string").count(); expect(tokenCount).toBeGreaterThan(0); }); @@ -254,17 +242,13 @@ test.describe("Endpoint screen", () => { "3 results", ); - await page - .locator('[data-test="method-filter"]') - .selectOption("POST"); + await page.locator('[data-test="method-filter"]').selectOption("POST"); await expect(page.locator('[data-test="search-results"]')).toContainText( "1 result", ); await expect(page.locator('[data-test="request"]')).toHaveCount(1); - await page - .locator('[data-test="method-filter"]') - .selectOption(""); + await page.locator('[data-test="method-filter"]').selectOption(""); await expect(page.locator('[data-test="search-results"]')).toContainText( "3 results", ); diff --git a/e2e/tests/home-screen.spec.ts b/e2e/tests/home-screen.spec.ts index 891bdd2..0bd56f3 100644 --- a/e2e/tests/home-screen.spec.ts +++ b/e2e/tests/home-screen.spec.ts @@ -16,7 +16,9 @@ test.describe("Home screen", () => { }); test("create endpoint button is visible", async ({ page }) => { - await expect(page.locator('button[data-test="create-endpoint"]')).toBeVisible(); + await expect( + page.locator('button[data-test="create-endpoint"]'), + ).toBeVisible(); }); test("create endpoint button redirects to the endpoint screen", async ({ diff --git a/public/endpoint.js b/public/endpoint.js index f2899b1..d9f0057 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -73,10 +73,9 @@ }, deleteRequest(uuid) { - return fetch( - `/api/endpoints/${this.endpointId}/requests/${uuid}`, - { method: "DELETE" }, - ) + return fetch(`/api/endpoints/${this.endpointId}/requests/${uuid}`, { + method: "DELETE", + }) .then(() => { this.requests = this.requests.filter((r) => r.uuid !== uuid); }) diff --git a/public/index.js b/public/index.js index 35fa7f1..40e39c7 100644 --- a/public/index.js +++ b/public/index.js @@ -47,7 +47,11 @@ window.renderBody = function (body, headers) { } // Heuristic: looks like XML/HTML if it starts with '<' const trimmed = body.trimStart(); - if (trimmed.startsWith("<") && window.hljs && window.hljs.getLanguage("xml")) { + if ( + trimmed.startsWith("<") && + window.hljs && + window.hljs.getLanguage("xml") + ) { return window.hljs.highlight(body, { language: "xml" }).value; } return window.htmlEscape(body); diff --git a/src/application.go b/src/application.go index f37ae8c..2b88b9a 100644 --- a/src/application.go +++ b/src/application.go @@ -7,7 +7,9 @@ import ( "net" "net/http" "os" + "regexp" "strconv" + "strings" "sync" "time" @@ -34,9 +36,11 @@ const ( var isProduction = os.Getenv("APPLICATION_ENV") == "production" -// Headers stripped from captured requests so users see their original payload, -// not infrastructure-added headers. +// Generic forwarding headers stripped from every captured request so users +// see their original payload, not infrastructure-added headers. Vendor headers +// specific to a hosting platform are stripped separately, see platformConfig. var omittedHeaders = [...]string{ + "Cdn-Loop", "Trace", "Traceparent", "Tracestate", @@ -51,6 +55,73 @@ var omittedHeaders = [...]string{ "X-Request-Start", } +// platformConfig describes how a hosting platform exposes request metadata: +// which header carries the real client IP, and which vendor headers it adds +// that should be hidden from captured requests — users inspect their own +// traffic and shouldn't have to care which provider sits in front of httphq. +type platformConfig struct { + ipHeader string // header with the real client IP; "" = TCP peer + ipList bool // ipHeader is a comma-separated list; take the leftmost + stripPrefix []string // captured-request header prefixes to drop as vendor noise +} + +// platforms maps the PLATFORM env var to its config. Each platform overwrites +// (or reliably sets) its own headers; the operator is responsible for ensuring +// traffic cannot reach the app bypassing the platform. +var platforms = map[string]platformConfig{ + "direct": {}, + "cloudflare": {ipHeader: "Cf-Connecting-Ip", stripPrefix: []string{"Cf-"}}, + "fly": {ipHeader: "Fly-Client-Ip", stripPrefix: []string{"Fly-"}}, + "heroku": {ipHeader: "X-Forwarded-For", ipList: true}, + "render": {ipHeader: "X-Forwarded-For", ipList: true}, + "proxy": {ipHeader: "X-Forwarded-For", ipList: true}, +} + +// currentPlatform is the config resolved once from PLATFORM at startup. +var currentPlatform platformConfig + +// resolvePlatform maps a PLATFORM value to its config. An empty value means +// "direct"; an unrecognised value fails safe to "direct" so a typo never +// causes a spoofable header to be trusted. +func resolvePlatform(name string) platformConfig { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + name = "direct" + } + if p, ok := platforms[name]; ok { + return p + } + slog.Warn("unknown PLATFORM, falling back to direct", "platform", name) + return platforms["direct"] +} + +// omitHeader reports whether a captured-request header is infrastructure noise +// — a generic forwarding header or a vendor header added by the configured +// PLATFORM — and so should be hidden from the user. +func omitHeader(name string) bool { + for _, h := range omittedHeaders { + if strings.EqualFold(name, h) { + return true + } + } + for _, prefix := range currentPlatform.stripPrefix { + if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) { + return true + } + } + return false +} + +// endpointIDPattern bounds an endpoint ID to the shape haikunator emits +// (lowercase words and digits joined by hyphens). Rejecting anything else +// keeps attacker-controlled characters — quotes, angle brackets, parens — +// out of the rendered pages, the database, and the logs. +var endpointIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func validEndpointID(id string) bool { + return len(id) <= 64 && endpointIDPattern.MatchString(id) +} + // socketRegistry tracks WS UUIDs subscribed to each endpoint, so the capture // hot path can fan-out without a DB round-trip. type socketRegistry struct { @@ -111,31 +182,23 @@ func (r *socketRegistry) count() int { return n } -// resolveClientIP picks the most trustworthy client IP available. Behind Traefik, -// X-Real-Ip is set to the real peer; we prefer it but verify it parses, and fall -// back through X-Forwarded-For (leftmost) and the TCP peer. +// resolveClientIP returns the real client IP per the configured PLATFORM +// strategy: it reads the platform's client-IP header (leftmost entry when the +// header is a list) and validates it parses. With no platform configured, or +// when the header is missing or malformed, it falls back to the TCP peer. func resolveClientIP(c fiber.Ctx) string { - if v := c.Get("X-Real-Ip"); v != "" { - if ip := net.ParseIP(v); ip != nil { - return ip.String() - } - } - if xff := c.Get(fiber.HeaderXForwardedFor); xff != "" { - // leftmost - for i := 0; i < len(xff); i++ { - if xff[i] == ',' { - if ip := net.ParseIP(trimSpace(xff[:i])); ip != nil { - return ip.String() - } - break + if currentPlatform.ipHeader != "" { + v := c.Get(currentPlatform.ipHeader) + if currentPlatform.ipList { + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] } } - if ip := net.ParseIP(trimSpace(xff)); ip != nil { + if ip := net.ParseIP(trimSpace(v)); ip != nil { return ip.String() } } - peer := c.IP() - if ip := net.ParseIP(peer); ip != nil { + if ip := net.ParseIP(c.IP()); ip != nil { return ip.String() } return "" @@ -160,6 +223,12 @@ func main() { } logging.Init("httphq", env) + /* Client IP strategy */ + + currentPlatform = resolvePlatform(os.Getenv("PLATFORM")) + slog.Info("platform resolved", + "platform", os.Getenv("PLATFORM"), "ip_header", currentPlatform.ipHeader) + /* Database */ database.Connect("file:local.db?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL") @@ -196,10 +265,11 @@ func main() { Views: engine, ViewsLayout: "layouts/main", BodyLimit: bodyLimit, - // Trust the upstream proxy (Traefik in cluster) so c.IP, the limiter, and - // header-based extraction reflect the real client. Pod CIDRs vary per - // cluster; restrict via env if you need a tighter list. - TrustProxy: true, + // Trust the upstream proxy only when a PLATFORM is configured, so + // c.Scheme() honours X-Forwarded-Proto (correct ws/wss + EndpointURL). + // With no platform, the app is treated as directly exposed and + // c.IP()/c.Scheme() reflect the real connection. + TrustProxy: currentPlatform.ipHeader != "", ProxyHeader: fiber.HeaderXForwardedFor, }) @@ -214,6 +284,10 @@ func main() { application.Use(limiter.New(limiter.Config{ Max: maxRequestsPerMinute, Expiration: 1 * time.Minute, + // Bucket on the spoof-resistant client IP (CF-Connecting-IP behind + // Cloudflare) rather than c.IP(), which trusts a client-supplied + // X-Forwarded-For and lets a caller reset its own limit. + KeyGenerator: func(c fiber.Ctx) string { return resolveClientIP(c) }, })) application.Use(compress.New()) @@ -255,7 +329,12 @@ func main() { return fiber.ErrUpgradeRequired }) - application.Get("/ws/:endpoint", socketio.New(func(kws *socketio.Websocket) { + application.Get("/ws/:endpoint", func(c fiber.Ctx) error { + if !validEndpointID(c.Params("endpoint")) { + return c.SendStatus(http.StatusNotFound) + } + return c.Next() + }, socketio.New(func(kws *socketio.Websocket) { endpointID := kws.Params("endpoint") kws.SetAttribute("endpointID", endpointID) registry.add(endpointID, kws.UUID) @@ -287,6 +366,9 @@ func main() { application.Get("/api/endpoints/:endpoint/requests", func(c fiber.Ctx) error { endpointID := c.Params("endpoint") + if !validEndpointID(endpointID) { + return c.SendStatus(http.StatusNotFound) + } search := c.Query("search") requests := database.GetRequestsForEndpointID(c.Context(), endpointID, search, 128) return c.JSON(fiber.Map{ @@ -296,11 +378,17 @@ func main() { application.Delete("/api/endpoints/:endpoint/requests", func(c fiber.Ctx) error { endpointID := c.Params("endpoint") + if !validEndpointID(endpointID) { + return c.SendStatus(http.StatusNotFound) + } database.DeleteRequestsForEndpointID(c.Context(), endpointID) return c.SendStatus(http.StatusOK) }) application.Delete("/api/endpoints/:endpoint/requests/:request", func(c fiber.Ctx) error { + if !validEndpointID(c.Params("endpoint")) { + return c.SendStatus(http.StatusNotFound) + } requestUUID := c.Params("request") database.DeleteRequestForUUID(c.Context(), requestUUID) return c.SendStatus(http.StatusOK) @@ -320,6 +408,9 @@ func main() { application.Get("/:endpoint", func(c fiber.Ctx) error { endpointID := c.Params("endpoint") + if !validEndpointID(endpointID) { + return c.SendStatus(http.StatusNotFound) + } host := string(c.Request().Host()) scheme := c.Scheme() websocketScheme := "ws" @@ -341,9 +432,12 @@ func main() { }) application.Use("/to/:endpoint", func(c fiber.Ctx) error { - requestUUID := uuid.NewString() - endpointID := c.Params("endpoint") + if !validEndpointID(endpointID) { + return c.SendStatus(http.StatusNotFound) + } + + requestUUID := uuid.NewString() ip := resolveClientIP(c) @@ -374,8 +468,10 @@ func main() { headers["Content-Type"] = []string{"application/x-www-form-urlencoded"} headers["User-Agent"] = []string{"curl/7.79.1"} } - for _, omittedHeader := range omittedHeaders { - delete(headers, omittedHeader) + for k := range headers { + if omitHeader(k) { + delete(headers, k) + } } // Flatten []string headers to a {key: scalar-or-array} JSON; the UI diff --git a/src/views/contact.html b/src/views/contact.html index d4579fe..20f1c84 100644 --- a/src/views/contact.html +++ b/src/views/contact.html @@ -3,7 +3,9 @@
-

Get in touch

+

+ Get in touch +

Found a bug, have an idea, or want to say hi? We read every message.

@@ -24,7 +26,10 @@

Get in touch

/>
-
-
-
-
+

We typically reply within a couple of days.

diff --git a/src/views/endpoint.html b/src/views/endpoint.html index 90ab233..82dbeb7 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -16,18 +16,26 @@
{{.EndpointURL}} @@ -37,18 +45,34 @@
-
+
- Send a test request - @@ -57,7 +81,10 @@ class="border-t border-slate-200 p-4 sm:p-5 space-y-3" >