Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ first image is `wordpress` (WordPress served by Caddy on PHP-FPM).
- `/healthz` is a dedicated PHP-backed endpoint that returns `200`, reached
by the container's own Docker/Coolify health check from `localhost`. HTTP
Basic Auth is enforced **by the image** (Caddy) from the `WORKSPACE_AUTH_*`
secret env vars (portal-plugin-ipfs PR #1031) on requests from **external**
(public-address) peers; loopback and private-network (RFC1918/ULA) peers
bypass it via Caddy's `remote_ip` (real peer, never spoofable forwarding
headers). Missing/invalid `WORKSPACE_AUTH_*` fails the container closed. Do
not weaken this to trust forwarding headers or to add an auth-disabled mode.
secret env vars (portal-plugin-ipfs PR #1031) on requests whose **resolved
client IP** is external (public-range); loopback and private-network
(RFC1918/ULA) resolved clients bypass it via Caddy's `client_ip` (resolved
through `trusted_proxies`: the private-range Coolify proxy's
`X-Forwarded-For`, or the raw peer when there is no forwarded chain — so a
proxied public client still authenticates while in-network peers and the
localhost health probe stay exempt). Direct spoofed forwarding headers from a
public peer are ignored (untrusted peer). Missing/invalid
`WORKSPACE_AUTH_*` fails the container closed. Do not weaken this to an
auth-disabled mode or to a peer-IP-based (`remote_ip`) bypass — that would
exempt the private-range proxy peer and silently disable auth for the whole
internet.
- The **Cast** plugin is platform-managed: baked from the latest GitHub
`develop` tree snapshot (Composer deps vendored at build; not checksum-pinned
yet) and force-kept active by an image-owned MU plugin. Plugin/theme editing
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,15 @@ it as a named context (`contexts.base = target:php-caddy`).
- **`/healthz`**: dedicated PHP-backed health probe, **loopback-exempt** (the
container's Docker health check reaches it from `localhost` without auth).
- **HTTP Basic Auth**: enforced by the image from the `WORKSPACE_AUTH_USERNAME`
/ `WORKSPACE_AUTH_PASSWORD` secret env vars on all requests from **external**
(public-address) peers (portal-plugin-ipfs PR #1031) — not by the Coolify
proxy. Bypass keys on the real peer IP (`remote_ip`) for loopback and
private-network (RFC1918/ULA) peers — in-deployment probes (e.g. Cast's
anonymous export probe hairpinning through the published port) and the health
check stay unauthenticated; never spoofable forwarding headers; missing
credentials fail the container closed.
/ `WORKSPACE_AUTH_PASSWORD` secret env vars on all requests whose **resolved
client IP** is external/public (portal-plugin-ipfs PR #1031) — including
every proxied request where the Coolify proxy forwards a public-range
`X-Forwarded-For` — not by the Coolify proxy. Bypass keys on the
private-range resolved client IP via Caddy's `client_ip` through
`trusted_proxies`: in-deployment probes (e.g. Cast's anonymous export probe
hairpinning through the published port) and the health check stay
unauthenticated; a public-range peer's spoofed forwarding headers are
ignored (untrusted); missing credentials fail the container closed.
- DB via `WORDPRESS_DB_HOST/PORT/NAME/USER/PASSWORD`; public URL via
`COOLIFY_URL` (sets `WP_HOME`/`WP_SITEURL` and drives `wp core install`);
`X-Forwarded-*` drives HTTPS-correct links.
Expand Down
71 changes: 50 additions & 21 deletions images/php-caddy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,34 @@
# is never baked into the image, written to disk, or echoed to logs — it lives
# only transiently in the process environment.
#
# Loopback + private-network exemption: direct requests whose real peer IP is
# Loopback + private-network exemption: requests whose resolved client IP is
# 127.0.0.1, ::1, or an RFC1918/ULA private address bypass auth. Loopback
# covers the container's own Docker/Coolify health probe
# (curl localhost:${PORT}/healthz). Private ranges cover in-deployment peers
# that the published-port hairpin NATs (e.g. the bridge gateway when a service
# inside the deployment's Docker network requests the public URL, as Cast's
# anonymous export probe and capture fetches do via wp_remote_get(home_url)).
# Exposing the site to such in-network peers is the existing contract: TLS and
# public ingress already terminate on a private-network peer (the Coolify
# proxy), so "protect from the internet" is the threat model, not
# "protect from the deployment's own network". The bypass keys on the actual
# TCP peer via Caddy's remote_ip matcher — NEVER on spoofable
# X-Forwarded-For / X-Real-IP headers, which this matcher ignores by default
# (trusted_proxies below influences only forwarded-header handling, never the
# remote_ip matcher).
# (curl localhost:${PORT}/healthz), which sends no forwarded headers. Private
# ranges cover in-deployment peers: the upstream Coolify proxy connects from a
# private address and forwards each public client's real (public-range) IP in
# X-Forwarded-For, while in-deployment services that request the public URL
# (as Cast's anonymous export probe and capture fetches do via
# wp_remote_get(home_url)) arrive either with a private-range XFF hop or as a
# direct hairpinned private peer — all resolve to a private client IP.
#
# The bypass therefore keys on Caddy's client_ip matcher, which resolves the
# client IP through the trusted_proxies below (+ X-Forwarded-For), NOT on the
# raw TCP peer alone. This matters because the Coolify proxy's own private
# peer address would exempt ALL proxied traffic if the bypass keyed on
# remote_ip — public clients must still authenticate.
#
# Trust assumptions: private-range peers are trusted, so their forwarding
# headers resolve the client IP (a public-range client is never trusted, so a
# direct attacker's spoofed X-Forwarded-For / X-Real-IP is ignored and the
# real peer is used). trusted_proxies_strict (set in the global block above)
# is required for this to hold against proxied clients: proxies append to
# X-Forwarded-For, so the leftmost entry is client-controlled; strict parsing
# scans right-to-left and takes the first untrusted address, the one the proxy
# actually appended. The proxy is assumed to always set X-Forwarded-For
# (Traefik/Caddy, Coolify's proxies, do by default); an XFF-less proxy would
# resolve to its own private IP and be exempt, which is documented as an
# accepted part of the private-network contract.
#
# The workspace always runs behind the upstream Coolify proxy, which terminates
# TLS and forwards plain HTTP on a private network. Caddy by default treats
Expand All @@ -41,12 +54,25 @@
# private_ranges is as narrow as a generic image can get: the upstream proxy's
# source IP varies per deployment (Podman/Docker bridge, overlay network), so
# the exact CIDR cannot be pinned at build time. Trusting all private peers is
# an accepted risk: exploiting it means forging X-Forwarded-Proto/Host on a
# request that ALREADY originates from inside the deployment's private network,
# and the headers only influence scheme/host derivation, never authorization.
# an accepted risk: exploiting it means forging forwarded headers on a request
# that ALREADY originates from inside the deployment's private network —
# X-Forwarded-Proto/Host influence only scheme/host derivation, and a forged
# X-Forwarded-For value would resolve the client to a private address — except
# that trusted_proxies_strict parses right-to-left, so the proxy-appended
# rightmost address (never spoofable by an outside client) anchors the
# decision: a forged private leftmost entry cannot grant an outside client a
# bypass.
{
servers {
trusted_proxies static private_ranges
# Parse X-Forwarded-For right-to-left (first untrusted IP) instead of
# the default left-to-right scan. With the default, a client behind the
# Coolify proxy could spoof a private-range leftmost XFF value (e.g.
# "X-Forwarded-For: 127.0.0.1") and have client_ip resolve to it,
# bypassing basic auth internet-wide. Strict parsing anchors on the
# rightmost, proxy-appended address, which no outside client controls.
# Available since Caddy v2.8; this image pins the current stable.
trusted_proxies_strict
}
}

Expand All @@ -56,13 +82,16 @@
root * /var/www/html
encode gzip

# Enforce HTTP Basic Auth on every request from outside the private
# network (including /healthz): the health path is only ever probed from
# localhost by the container health check, so a non-exempt /healthz is
# just another gated route. The username and derived hash names are provided by the
# Enforce HTTP Basic Auth on every request whose resolved client IP is
# outside the loopback/private set (including /healthz): the health path is
# only ever probed from localhost by the container health check, so a
# non-exempt /healthz is just another gated route. client_ip (not
# remote_ip) is required here: every proxied request's TCP peer is the
# private-range Coolify proxy, so a peer-based matcher would exempt the
# entire internet. The username and derived hash names are provided by the
# auth setup step; missing/invalid WORKSPACE_AUTH_* fails the container
# at start, so this block is always populated when Caddy runs.
@external not remote_ip 127.0.0.1 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7
@external not client_ip 127.0.0.1 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7
Comment thread
kody-ai[bot] marked this conversation as resolved.
basicauth @external {
{$WORKSPACE_AUTH_USERNAME} {$WORKSPACE_AUTH_HASH}
}
Expand Down
66 changes: 47 additions & 19 deletions images/php-caddy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

Reusable base image: **PHP-FPM + Caddy**, the runtime every Pinner workspace
image builds on. Non-root after startup, no integrated app logic. Enforces
workspace HTTP Basic Auth on public (non-loopback) requests.
workspace HTTP Basic Auth on requests whose resolved client IP is public
(proxied public clients included).

| Aspect | Value |
|---|---|
Expand All @@ -26,28 +27,50 @@ the same values) and rebuild. Verify with `make deps-verify`.
## HTTP Basic Auth (portal-plugin-ipfs PR #1031)

The Pinner portal injects two **secret** environment variables, and this image
enforces them with Caddy's `basicauth` on every request from an **external**
(public-address) peer:
enforces them with Caddy's `basicauth` on every request whose **resolved client
IP** is external (public-range):

- `WORKSPACE_AUTH_USERNAME`
- `WORKSPACE_AUTH_PASSWORD`

Behavior:

- **External** requests (the actual TCP peer is a public address) must present
valid HTTP Basic Auth credentials, otherwise they get `401`.
- **Loopback** requests (real peer `127.0.0.1` or `::1`) and **private-network**
- **External** requests (resolved client IP is a public address) must present
valid HTTP Basic Auth credentials, otherwise they get `401`. This includes
every request forwarded by the Coolify proxy for a public client: the proxy
is a private-range trusted peer whose `X-Forwarded-For` carries the real
public client IP. Keying the bypass on the raw TCP peer instead would exempt
the proxy's own private-range peer address and silently disable auth for the
whole internet.
- **Loopback** requests (client IP `127.0.0.1` or `::1`) and **private-network**
requests (RFC 1918 / ULA ranges: `10.0.0.0/8`, `172.16.0.0/12`,
`192.168.0.0/16`, `fc00::/7`) bypass auth. Loopback covers the container's
own Docker/Coolify health probe of `/healthz`. Private-network coverage keeps
in-deployment consumers — the Coolify proxy's plain-HTTP hop, and any service
inside the deployment's Docker network that requests the workspace's *public*
URL and arrives hairpinned through the published port with the bridge
gateway as its peer (e.g. Cast's anonymous export probe and capture fetches
via `wp_remote_get(home_url('/'))`) — outside "the internet" in the auth
own Docker/Coolify health probe of `/healthz` (it sends no forwarded
headers, so its client IP is the loopback peer itself). Private-network
coverage keeps in-deployment consumers — the Coolify proxy's plain-HTTP hop
for private-range forwarded clients, and any service inside the deployment's
Docker network that requests the workspace's *public* URL (e.g. Cast's
anonymous export probe and capture fetches via
`wp_remote_get(home_url('/'))`, arriving with a private-range XFF hop or as
a direct hairpinned private peer) — outside "the internet" in the auth
threat model. The portal itself never HTTP-probes the workspace URL.
- The bypass keys on Caddy's `remote_ip` matcher — the **real peer IP**, never
spoofable `X-Forwarded-For` / `X-Real-IP` headers.
- The bypass resolves the client IP with Caddy's `client_ip` matcher through
the `trusted_proxies` setting (private ranges): a private-range peer's
`X-Forwarded-For` determines the client IP, while a public-range peer is
untrusted, so its (attacker-controlled) `X-Forwarded-For` / `X-Real-IP` is
ignored and the real peer address is used. `trusted_proxies_strict` makes
the XFF chain parse right-to-left (first untrusted address, skipping the
trusted proxies that append to it): the leftmost entries are
client-controlled, so the default left-to-right parsing would let a proxied
client spoof `X-Forwarded-For: 127.0.0.1` and bypass auth internet-wide. A
client cannot gain a bypass in strict mode either — the proxy appends the
peer it actually saw, so the chain never *ends* in a spoofable private
value; forging a *public* XFF entry only moves the request into the
authenticated "external" set.
- Trust assumption: the deployment's proxy always sets `X-Forwarded-For`
(Coolify's Traefik/Caddy do by default). An XFF-less private proxy would
resolve to its own private IP and be exempt — an accepted part of the
private-network contract below.
- **Fail closed**: if either `WORKSPACE_AUTH_*` var is missing/empty at start,
the container refuses to start rather than serve the workspace publicly
unauthenticated. This matches the portal's environment builder, which fails
Expand All @@ -74,16 +97,21 @@ the global `servers > trusted_proxies static private_ranges` option: requests
from the private-network proxy are trusted and their forwarded headers
(`X-Forwarded-Proto` / `X-Forwarded-Host`) reach the application unmodified.
This keeps WordPress `is_ssl()` correct behind TLS termination and avoids the
`force_ssl_admin()` login redirect loop. The trust setting does **not** relax
the Basic Auth bypass, which continues to key on the real peer IP (`remote_ip`),
never on forwarded headers.
`force_ssl_admin()` login redirect loop. The same trust decision is what makes
the Basic Auth bypass resolve the real client IP: a private-range peer's
forwarded headers honor `X-Forwarded-For`, while a public-range (untrusted)
peer's spoofed headers are ignored.

`private_ranges` is as narrow as a generic image can be pinned to: the upstream
proxy's source IP varies per deployment (Docker bridge / overlay network), so an
exact proxy CIDR cannot be known at build time. Trusting all private-range peers
is an accepted risk: an attacker would need to already sit on the deployment's
private network, and the forwarded headers only influence scheme/host
derivation in the application — never authorization.
private network. Forged `X-Forwarded-Proto` / `X-Forwarded-Host` influence only
scheme/host derivation in the application; a forged *private-range*
`X-Forwarded-For` can at most move a request from the authenticated
"in-network peer" case into the auth-exempt private set it would already fall
into, and a forged *public-range* one strictly requires more auth — never a
bypass for an outside client.

## Health

Expand Down
14 changes: 9 additions & 5 deletions images/wordpress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,15 @@ the portal via `PORTAL_API_KEY` / `COOLIFY_URL`.
Public HTTP Basic Auth is enforced **by the image's Caddy layer** from the
`WORKSPACE_AUTH_USERNAME` / `WORKSPACE_AUTH_PASSWORD` secret env vars injected
by the portal (portal-plugin-ipfs PR #1031) — it is no longer the Coolify
proxy's responsibility. All non-loopback requests require valid credentials
(`401` otherwise); loopback requests (real peer `127.0.0.1` / `::1`) bypass
auth so the container's Docker health check of `/healthz` passes. The bypass
never trusts spoofable `X-Forwarded-For` / `X-Real-IP`. Missing credentials
fail the container closed. See [`images/php-caddy/README.md`](../php-caddy/README.md).
proxy's responsibility. All requests with a public-range resolved client IP
require valid credentials (`401` otherwise) — including every request the
Coolify proxy forwards for a public client; loopback requests (client IP
`127.0.0.1` / `::1`) and private-network clients bypass auth so the container's
Docker health check of `/healthz` and in-deployment probes (e.g. Cast) pass.
The bypass resolves the client IP via Caddy's `client_ip` through
`trusted_proxies`: a public-range peer's spoofed `X-Forwarded-For` /
`X-Real-IP` is ignored (untrusted peer). Missing credentials fail the
container closed. See [`images/php-caddy/README.md`](../php-caddy/README.md).

## Security trade-off (privilege drop)

Expand Down
Loading
Loading