feather-reader 0.3.10

A minimalist, atproto-native RSS/Atom reader in Rust — your feed subscriptions live in your own PDS.
Documentation
# FeatherReader — in-container Caddy edge.
#
# This is the ONLY listener bound off-loopback (it is the Fly internal_port).
# TLS is terminated by Cloudflare (Full-strict) + Fly's force_https in front, so
# Caddy itself serves plain HTTP on :8080 with automatic HTTPS turned off.
#
# Routing (grounded in the source):
#   * OAuth routing is SPLIT into two importable files, one per backend, chosen
#     by FEATHERREADER_REPO_BACKEND at container start
#     (deploy/caddy-oauth-sidecar.conf and deploy/caddy-oauth-rust.conf). The
#     notes below describe the sidecar variant, which is the default.
#   * The sidecar's PUBLIC OAuth endpoints live at its ROOT (/login, /callback,
#     /client-metadata.json, /jwks.json). In prod SIDECAR_PUBLIC_URL is
#     https://feather-reader.com/oauth, so client_id/redirect_uri are under
#     /oauth/* — Caddy proxies /oauth/* to the sidecar with the /oauth prefix
#     STRIPPED.
#   * The Rust app owns everything else (the htmx UI, /health, /static/*, and its
#     OWN post-login callback route /oauth/callback).
#   * COLLISION: both the sidecar's atproto redirect_uri and the Rust app's
#     callback are the literal path /oauth/callback. They are disambiguated by
#     query param (grounded in oauth-sidecar/src/server.ts):
#       - PDS  -> SIDECAR (/callback):  code=&state=  (success)  OR  error=&state=
#         (user denied / OAuth error). NEITHER carries session_id or
#         error_description. This MUST reach the sidecar — only it holds the
#         per-request PKCE/state row and can call oauthClient.callback().
#       - SIDECAR -> RUST APP:  ?session_id=<id>  on success (server.ts:196), or
#         ?error=OAuthCallbackFailed&error_description=<msg>  on the sidecar's own
#         failure hand-off (server.ts:200-204). Both are app-bound.
#     So we route /oauth/callback to the APP when it carries `session_id` OR
#     `error_description` (the sidecar->app hop markers), and let everything else
#     under /oauth/callback (crucially the PDS's `?error=&state=` deny path) fall
#     through to the sidecar. Handled BEFORE the generic /oauth/* rule.
#   * /internal/* is the sidecar's shared-secret server-to-server API and MUST
#     NOT be publicly reachable. In this topology the Rust app reaches it over
#     loopback (SIDECAR_INTERNAL_URL=http://127.0.0.1:8081), so it never needs to
#     be edge-routable. We block BOTH the bare /internal/* AND the prefixed
#     /oauth/internal/* (which would otherwise fall through handle_path /oauth/*
#     and reach the sidecar's /internal/* after prefix strip). Combined with the
#     sidecar binding loopback-only and its own X-Internal-Secret guard, the
#     internal API is genuinely unreachable from outside the container.

{
	# Cloudflare + Fly terminate TLS; no ACME here.
	auto_https off
	# Trust the loopback proxy chain only; the real client IP arrives as a header
	# that we re-assert below (the Rust app validates it via
	# FEATHERREADER_TRUSTED_IP_HEADER=cf-connecting-ip).
	servers {
		trusted_proxies static private_ranges
	}
	admin off

	# **The redaction below must ALSO cover the error logger, not just the access
	# log.** A site-level `log` directive configures only `http.log.access.*`.
	# Errors during handling — every 502/503 from an upstream that is down, slow
	# or restarting — are emitted by `http.log.error.*`, a DIFFERENT logger that
	# inherits nothing from it and dumps the full request, headers and all.
	#
	# Measured, not assumed: with only the site-level filter in place, a request
	# that failed upstream logged `"X-Origin-Auth":["<the secret>"]` verbatim from
	# `http.log.error.log0` while `http.log.access.log0` correctly showed
	# `REDACTED`. So the leak would have survived the fix, and would have fired on
	# exactly the occasions an operator tails the logs — an outage.
	# **Named, with `include http.log.error` — NOT a reconfigured `default`.** The
	# adapter then adds `http.log.error` to `default`'s `exclude`, so there is no
	# duplicate emission and `default` keeps its own writer and encoder.
	#
	# Reconfiguring `default` directly also redacted correctly, but it silently
	# re-pointed EVERY runtime log line: stderr->stdout, JSON->console with raw
	# ANSI colour embedded (measured 1 stdout / 15 stderr before, 17 / 0 after).
	# Restating `default`'s real shape by hand fixes that only for as long as the
	# restatement stays accurate. This form cannot drift out of sync with it.
	log origin_errors {
		output stderr
		include http.log.error
		format filter {
			wrap json
			fields {
				request>headers>X-Origin-Auth replace REDACTED
				request>headers>Cookie replace REDACTED
				request>uri query {
					replace code REDACTED
					replace state REDACTED
					replace session_id REDACTED
				}
			}
		}
	}
}

:8080 {
	# **`X-Origin-Auth` must never reach the log.** It IS the origin lock — the
	# shared secret Cloudflare injects and the matcher below compares against —
	# and the console encoder writes every request header verbatim, so until this
	# filter existed the secret appeared in full on EVERY access-log line. That
	# put it in `fly logs`, in any log drain, and in the scrollback of anyone who
	# ever tailed this app. A control whose key is published beside every request
	# it guards is not a control.
	#
	# Caddy redacts the standard credential headers by default, which is exactly
	# why this one slipped: `X-Origin-Auth` is a custom name, so nothing knew to
	# treat it as a secret. `Cookie` is filtered here too rather than trusted to
	# that default. Caddy's built-in redaction already covers `Cookie`,
	# `Authorization`, `Proxy-Authorization` and `Set-Cookie`, as a literal
	# `REDACTED` replacement rather than a hash — so this is belt-and-braces, not
	# a fix. It uses `replace` to match that shape: `delete` drops the field
	# entirely and loses the presence signal the next paragraph argues for.
	#
	# `replace`, not `delete`, for the origin header: it keeps the FIELD while
	# losing the value, so a 403 still distinguishes "arrived without the header"
	# (bypassed Cloudflare) from "arrived with the wrong one" (stale secret,
	# mid-rotation). Deleting it would collapse those into the same log line, and
	# they call for opposite responses.
	# **The OAuth query string is a credential too.** The header filters above
	# leave `uri` untouched, and `/oauth/callback` carries the PDS's `?code=` —
	# a single-use authorization code — plus the `state` that binds it to the
	# pending row. Until this filter existed both appeared verbatim on every
	# callback access-log line, which is the same distribution the paragraph
	# above objects to for the origin secret: `fly logs`, any drain, anyone's
	# scrollback. A logged code is not directly replayable (it is one-shot, and
	# the exchange also demands the PKCE verifier, a DPoP proof and
	# `private_key_jwt`), so this is hygiene rather than an open hole — but the
	# argument for redacting the header is the argument for redacting these.
	#
	# `replace`, not `delete`, for the same reason as above: the field survives
	# with its value gone, so a failed callback still shows WHICH parameters
	# arrived. `iss`, `error` and `error_description` are deliberately NOT
	# filtered — they name the issuer and say why a handshake failed, they are
	# not secrets, and they are most of the diagnostic value of these lines.
	#
	# This is a LOG filter and touches nothing else: the `/oauth/callback`
	# routing below still matches on `session_id`/`error_description` as before.
	log {
		output stdout
		format filter {
			wrap console
			fields {
				request>headers>X-Origin-Auth replace REDACTED
				request>headers>Cookie replace REDACTED
				request>uri query {
					replace code REDACTED
					replace state REDACTED
					replace session_id REDACTED
				}
			}
		}
	}

	# --- Origin lock: require Cloudflare's injected shared secret --------------
	# Cloudflare fronts this app (Full-strict) and sets `X-Origin-Auth` on every
	# request via a Transform Rule. A request that reaches the Fly origin WITHOUT
	# it bypassed Cloudflare (someone hit the origin IP directly) — reject it, so
	# the cf-connecting-ip trust (rate limiting) and the edge WAF/cache rules
	# can't be sidestepped. The secret comes from the FEATHERREADER_ORIGIN_SECRET
	# env (a `fly secret`), never baked into this file/image.
	#
	# `/health` is EXEMPT: Fly's internal health probe hits it directly (not
	# through Cloudflare, so no header) — gating it would flap the machine
	# unhealthy.
	#
	# That exemption BOUNDS what /health may say. It used to return only a static
	# "ok featherreader/<ver>" string; it now also reports whether the database is
	# reachable, whether the poll loop is ticking, whether fetching is paused, and
	# which OAuth backend is live — so an operator can diagnose an instance whose
	# feeds have stopped, including during an OAuth outage when the session-gated
	# /admin/metrics is exactly as unreachable as the thing it would diagnose.
	#
	# Every one of those is a machine fact of the same class /stats already
	# publishes to anyone: no user counts, no DIDs, no feed URLs, and no precise
	# internal numbers. The version string in the same response already pins the
	# exact code, so the backend name discloses nothing further. Anything outside
	# that class must go behind the origin lock, not here.
	#
	# Placed FIRST so nothing below routes for a header-less request.
	#
	# This matcher alone does NOT fail closed, contrary to what this comment said
	# for months: with the secret unset it compares against "", which an
	# empty-valued header satisfies. The `@empty_origin_value` guard below is what
	# actually makes a missing or mistyped secret lock the door.
	@no_origin_secret {
		not path /health
		not header X-Origin-Auth {env.FEATHERREADER_ORIGIN_SECRET}
	}
	handle @no_origin_secret {
		respond 403
	}

	# **An EMPTY header value is a third case, and it FAILED OPEN.**
	#
	# `not header X-Origin-Auth {env.FEATHERREADER_ORIGIN_SECRET}` expands to
	# `not header X-Origin-Auth ""` when the secret is unset. An empty-valued
	# header then MATCHES the empty expected value, `not` inverts it, the matcher
	# above does not fire, and the request is proxied. Measured with the secret
	# unset: no header -> 403, junk -> 403, but `X-Origin-Auth:` with no value
	# -> **200**. The comment below claimed the opposite for months, and
	# `oauth-sidecar/src/client-ip.ts` founds the whole `cf-connecting-ip` trust
	# model on that claim.
	#
	# This makes the documented property true: an empty value is refused
	# regardless of what the secret is, so a missing or mistyped
	# FEATHERREADER_ORIGIN_SECRET locks the door rather than opening it.
	@empty_origin_value {
		not path /health
		header_regexp X-Origin-Auth ^$
	}
	handle @empty_origin_value {
		respond 403
	}

	# --- Hard block: the sidecar's internal API is never edge-routable --------
	# Matches the bare path AND the /oauth-prefixed path (the latter would else
	# fall through handle_path /oauth/* -> sidecar /internal/*). This block is
	# placed FIRST so nothing below can route to it.
	@internal path /internal /internal/* /oauth/internal /oauth/internal/*
	handle @internal {
		respond 404
	}

	# --- OAuth routing: selected by FEATHERREADER_REPO_BACKEND ----------------
	# container-entrypoint.sh installs ONE of deploy/caddy-oauth-{sidecar,rust}.conf
	# as /etc/caddy/oauth-routes.conf before Caddy starts. The two backends cannot
	# share /oauth/callback -- the PDS sends identical `?code=&state=&iss=` to both,
	# so nothing in the request distinguishes them and one process must own the
	# path. See either conf file for the full reasoning.
	import /etc/caddy/oauth-routes.conf

	# --- Everything else: the Rust featherreader app --------------------------
	# The UI, /health, and /static/* all live here.
	#
	# CLIENT-IP TRUST MODEL (important): the app reads the real visitor IP from
	# Cf-Connecting-Ip (FEATHERREADER_TRUSTED_IP_HEADER). Cloudflare sets that
	# header to the true visitor and STRIPS any client-supplied copy — so the
	# header is authoritative ONLY when every request provably transits CF. We do
	# NOT overwrite it here (that would replace the real visitor IP with the CF
	# edge IP and defeat the whole point). The required backstop is network-level:
	# the Fly app MUST be reachable ONLY via Cloudflare (Fly private networking /
	# a CF-IP allowlist), so a direct-to-origin request cannot forge the header.
	# See MANUAL STEP: lock origin to Cloudflare. Per src/web.rs this header only
	# keys the rate limiter (not auth), so the residual risk if the lockdown lapses
	# is rate-limit-bucket spoofing, not an auth bypass — but do the lockdown.
	handle {
		reverse_proxy 127.0.0.1:8082 {
			# The secret has done its job by here; no upstream reads it. Forwarding
			# it means a future `RUST_LOG=debug`, or any header-dumping middleware,
			# reopens exactly the leak this file closes. Repeated per proxy rather
			# than one `request_header`, because that directive is ordered BEFORE
			# `handle` and would strip the header before the lock's matcher reads
			# it — disabling the lock outright.
			header_up -X-Origin-Auth
		}
	}
}