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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.0
0.10.0
9 changes: 9 additions & 0 deletions api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1367,6 +1367,15 @@
"owner_username": {
"type": "string"
},
"public_paths": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"scope": {
"type": "string"
},
Expand Down
6 changes: 6 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,12 @@ components:
type: string
owner_username:
type: string
public_paths:
items:
type: string
type:
- array
- "null"
scope:
type: string
slug:
Expand Down
116 changes: 116 additions & 0 deletions dev/cloud/cloud-assertions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,29 @@ for c in $want; do
grep -qw "$c" <<<"$running" || fail "control-plane container '$c' not running after 120s (have: $running)"
done

# --- 5b. container stdout is readable through journald by CONTAINER_NAME — the
# EXACT query host-agent-real's per-app log tail runs
# (internal/hostagent/journalsource: `journalctl CONTAINER_NAME=<container>`).
# This is deliberately not a `docker logs` read: `docker logs` works on every log
# driver, which is precisely why the driver being wrong went unnoticed and every
# app's Logs tab hung on "Waiting for log output…" on a real box. Assert the
# driver, then assert the query it exists to serve actually returns lines.
log_driver="$(docker info --format '{{.LoggingDriver}}' 2>/dev/null || true)"
[ "$log_driver" = journald ] || \
fail "docker log driver is '$log_driver' (want journald) — the per-app Logs tab reads journalctl CONTAINER_NAME=, which only the journald driver populates"
# malmo-brain is the safe probe: it is up by now (step 5) and always writes
# startup milestones to stdout. Poll — journald ingest can lag container start
# by a beat under a loaded TCG boot, same race wait_brain_log documents.
brain_journal=""
for _i in $(seq 1 60); do
brain_journal="$(journalctl CONTAINER_NAME=malmo-brain -b --no-pager -n 5 -o cat 2>/dev/null || true)"
[ -n "$brain_journal" ] && break
sleep 1
done
[ -n "$brain_journal" ] || \
fail "journalctl CONTAINER_NAME=malmo-brain returned nothing after 60s — container stdout is not reaching journald, so the per-app Logs tab will hang for every app"
echo "cloud-assertions: container logs readable via journalctl CONTAINER_NAME= (driver=journald)"

# --- 6. proxy boundary: the brain reaches Docker only through the socket-proxy,
# never the raw socket (CONTROL_PLANE.md # Docker socket exposure).
brain_sock="$(docker inspect malmo-brain --format '{{range .Mounts}}{{println .Source}}{{end}}' 2>/dev/null | grep -c 'docker.sock' || true)"
Expand Down Expand Up @@ -329,6 +352,19 @@ full_get() { # PATH HOST [COOKIE] -> full response
cat <&3
exec 3>&- 3<&-
}
# Like full_get, plus one arbitrary extra request header. The path-scoped
# exposure probes (#415) need to send a FORGED X-Malmo-User and see what the app
# upstream received, which no cookie-only helper can do.
full_get_hdr() { # PATH HOST HEADER-LINE [COOKIE] -> full response
exec 3<>/dev/tcp/127.0.0.1/80 || return 1
if [ -n "${4:-}" ]; then
printf 'GET %s HTTP/1.0\r\nHost: %s\r\n%s\r\nCookie: %s\r\nConnection: close\r\n\r\n' "$1" "$2" "$3" "$4" >&3
else
printf 'GET %s HTTP/1.0\r\nHost: %s\r\n%s\r\nConnection: close\r\n\r\n' "$1" "$2" "$3" >&3
fi
cat <&3
exec 3>&- 3<&-
}
full_send() { # METHOD PATH HOST COOKIE JSON -> full response
local len; len="$(printf '%s' "$5" | wc -c | tr -d ' ')"
exec 3<>/dev/tcp/127.0.0.1/80 || return 1
Expand Down Expand Up @@ -669,6 +705,86 @@ access)
|| fail "access: restricted-app 302 Location is not the box login: $(grep -i '^Location:' <<<"$n_resp" | tr -d '\r')"
echo "cloud-assertions: restricted app gates an unauthenticated request (302 → box login)"

# 3b. PATH-SCOPED EXPOSURE (#415). The app is still restricted, and its manifest
# declares access.public_paths ["/v1", "/v1/*"]. The claim: those paths
# answer anonymously (an external SDK can post to the API) while every other
# path keeps the box login in front of it. This is the half a unit test
# cannot prove — Caddy matches a normalized path, the app sees the original
# URI, and that gap is where this bug class lives.
for p in /v1 /v1/traces "/v1/traces?x=1"; do
pp_resp="$(full_get "$p" "$app_host" 2>/dev/null || true)"
grep -q ' 200' <<<"$(status_of "$pp_resp")" && grep -qi 'Hostname:' <<<"$pp_resp" \
|| fail "access: declared public path $p did not reach the app anonymously: status='$(status_of "$pp_resp")'"
done
echo "cloud-assertions: declared public paths answer with no session (the token-authed API works while the UI stays gated)"

# 3c. THE FORGERY GUARD, and the reason the scrub is unconditional. The gate does
# not run on a public path, so nothing there would overwrite a caller-supplied
# X-Malmo-User. If the app got a brain-vouched header on one path and a forged
# one on another it could not tell them apart, and "malmo says this is the
# owner" would become "anyone on the internet says so".
fg_resp="$(full_get_hdr /v1/traces "$app_host" 'X-Malmo-User: attacker' 2>/dev/null || true)"
grep -qi 'Hostname:' <<<"$fg_resp" || fail "access: forged-header probe did not reach the app on a public path"
grep -qiE '^X-Malmo-User:' <<<"$fg_resp" \
&& fail "access: IDENTITY FORGERY — a client-supplied X-Malmo-User survived to the app upstream on a public path: $(grep -i '^X-Malmo-User:' <<<"$fg_resp" | tr -d '\r')"
# Same forgery on the GATED path, with the owner's cookie: the app must receive
# the brain's value, never the caller's.
fg2_resp="$(full_get_hdr / "$app_host" 'X-Malmo-User: attacker' "$fa_cookie" 2>/dev/null || true)"
grep -qi 'Hostname:' <<<"$fg2_resp" || fail "access: forged-header probe did not reach the app on the gated path"
grep -qiE '^X-Malmo-User: *attacker' <<<"$fg2_resp" \
&& fail "access: IDENTITY FORGERY — a client-supplied X-Malmo-User survived the gate: $(grep -i '^X-Malmo-User:' <<<"$fg2_resp" | tr -d '\r')"
grep -qiE '^X-Malmo-User:' <<<"$fg2_resp" \
|| fail "access: the gated path lost the vouched X-Malmo-User entirely (the scrub is deleting the brain's own value)"
echo "cloud-assertions: identity headers scrubbed on both branches (forged X-Malmo-User never reaches the app; the vouched one still does)"

# 3d. The #335 per-cookie strip holds on the public branch too — it is the same
# proxy handler on both sides of the subroute, and this proves it.
pc_resp="$(full_get /v1/traces "$app_host" "${fa_cookie}; probe=leakcheck" 2>/dev/null || true)"
grep -qi 'Hostname:' <<<"$pc_resp" || fail "access: public-path cookie probe did not reach the app"
grep -qiE '^Cookie:.*malmo_forward_auth=' <<<"$pc_resp" \
&& fail "access: COOKIE LEAK (public path) — the app upstream received malmo_forward_auth on a declared public path"
grep -qiE '^Cookie:.*probe=leakcheck' <<<"$pc_resp" \
|| fail "access: public path lost the app's own cookie — the strip is removing more than malmo_forward_auth"
echo "cloud-assertions: public paths strip only malmo_forward_auth (same proxy handler as the gated branch)"

# 3e. THE BYPASS TABLE — the point of running this through real Caddy. Every
# entry is a request that must NOT be treated as a public path. The requests
# are written raw onto the socket (no client-side normalization), so what
# Caddy matches is exactly what is asserted here:
# /v1extra "/v1/*" must not behave like a bare "/v1*" prefix, or
# a sibling route would be exposed by accident;
# /v1/../ and //v1/ path traversal and slash-merging: Caddy matches the
# cleaned path, so these resolve to the app root;
# /v1/%2e%2e/ the encoded form of the same, the case where a matcher
# and an app can disagree about what the path is;
# /V1x, /admin plain non-matches, the control.
# A 302 to the box login is the pass condition: the gate ran.
# The claim asserted for every entry is the one that matters: the app is
# NOT reached without a session. How the box says no differs by entry:
# gate — the forward_auth gate ran and 302'd to the box login;
# merge — Caddy collapses the duplicate slash and 301's to the
# normalized path BEFORE matching, so the app is never reached
# and the redirect target is then judged on its own merits
# (`/v1/` is genuinely public, `//admin` normalizes to a gated
# `/admin`). This was the one real correction the first CI run
# produced: the probe is safe, the expectation was wrong.
for probe in "/v1extra|gate" "/v1/../|gate" "//v1/|merge" "/v1/%2e%2e/|gate" "/V1x|gate" "/admin|gate"; do
bad="${probe%|*}"; want="${probe#*|}"
bp_resp="$(full_get "$bad" "$app_host" 2>/dev/null || true)"
bp_status="$(status_of "$bp_resp")"
grep -qi 'Hostname:' <<<"$bp_resp" \
&& fail "access: UNGATED PATH — '$bad' reached the app upstream with no session; it is not a declared public path"
case "$want:$bp_status" in
gate:*" 302"*) ;;
merge:*" 301"*)
grep -qiE '^Location:.*//v1/' <<<"$bp_resp" \
&& fail "access: UNGATED PATH — '$bad' redirected without collapsing the duplicate slash: $(grep -i '^Location:' <<<"$bp_resp" | tr -d '\r')"
;;
*) fail "access: UNGATED PATH — '$bad' answered '$bp_status', wanted $want; an undeclared path must never be served anonymously" ;;
esac
done
echo "cloud-assertions: undeclared paths stay closed (prefix footgun, traversal, encoded traversal, double slash, case variant)"

# 4. flip to PUBLIC via the exposure toggle (owner session; the endpoint is
# hosted-only + owner-or-admin). Resolve the instance id from the running
# container's malmo.instance_id label (whoami is FROM-scratch — no shell to
Expand Down
12 changes: 12 additions & 0 deletions dev/cloud/mkosi.conf
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,18 @@ Packages=
# the brain reads at startup (#202/C1a, internal/profile.Read) to select the
# hosted seams (e.g. skip mDNS publish). A static one-line file committed under
# mkosi.extra/.
#
# Also carries the container-logging wiring, which is load-bearing and easy to
# miss because JSON takes no comments. /etc/docker/daemon.json sets Docker's
# daemon-wide log driver to journald (LOGGING.md # Docker daemon uses the
# `journald` log driver). host-agent-real's per-app log tail runs
# `journalctl CONTAINER_NAME=<container>` (internal/hostagent/journalsource),
# and CONTAINER_NAME is a field ONLY the journald driver sets — under Docker's
# default json-file driver that match returns nothing and the dashboard's Logs
# tab hangs on "Waiting for log output…" forever, for every app. The paired
# docker.service.d/10-malmo-logging.conf drop-in lifts journald's per-unit rate
# limit for dockerd, because that routing puts every container on docker.service's
# single budget (LOGGING.md # Tuning).
ExtraTrees=mkosi.extra
# The first-boot runtime wiring (#242): the slim host-agent + its units, the baked
# control-plane image bundle + loader, the control-plane compose, the PAM stack,
Expand Down
3 changes: 3 additions & 0 deletions dev/cloud/mkosi.extra/etc/docker/daemon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"log-driver": "journald"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Docker is the deliberate exception to journald's per-unit rate limit
# (LOGGING.md # Tuning). /etc/docker/daemon.json routes every container's
# stdout through dockerd, and journald enforces its limit against
# _SYSTEMD_UNIT — so all containers on the box share ONE bucket attributed to
# docker.service. Under the default 10000-per-30s, a single chatty container
# makes journald silently drop messages from every other container and from
# dockerd itself, which surfaces as a per-app Logs tab that looks healthy while
# missing lines.
#
# Disabling the limit here removes that cross-container starvation without
# loosening it for real system services (sshd brute-force spam still caps).
# The journal's size cap is then the sole backpressure for container output —
# a misbehaving container ages useful history out faster than rate-limiting
# would, which LOGGING.md accepts on the grounds that silent drops are the
# worse debugging experience.
[Service]
LogRateLimitIntervalSec=0
LogRateLimitBurst=0
15 changes: 13 additions & 2 deletions dev/cloud/run-cloud-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,16 @@ run_boot() {
return 1
fi
echo "phase=${phase} verdict: ${v}"
# Match the verdict EXACTLY, not as a substring. The guest emits either
# "MALMO_CLOUD_ASSERTIONS: PASS" or "MALMO_CLOUD_ASSERTIONS: FAIL: <reason>",
# and the old `*PASS*` glob read any failure whose REASON happened to contain
# the letters "pass" as a pass. That is not hypothetical: a new assertion
# failing with "PATH GATE BYPASS — ..." turned a genuinely red access boot
# into a green CI run, printing "boot access OK" under a FAIL verdict it had
# just echoed (#415). Any word like bypass/passphrase/password in a failure
# message re-opens it, so anchor on the verdict word itself.
case "$v" in
*PASS*)
"MALMO_CLOUD_ASSERTIONS: PASS"*)
# Print what the guest proved, not only that it passed. A PASS used to
# discard every `cloud-assertions:` line (dump_serial runs on failure
# only), so a scenario that silently stopped asserting — a section
Expand Down Expand Up @@ -419,7 +427,10 @@ fi
# SSO → installs whoami air-gapped → proves the restricted gate (302 without a
# session, proxied-through WITH the owner's forward-auth cookie), the public toggle
# (reachable with no session), and the Cookie-strip invariant (the app upstream never
# receives the cookie) in both modes.
# receives the cookie) in both modes. The same app declares access.public_paths, so
# the boot also proves the path-scoped carve-out (#415) through real Caddy: declared
# paths answer anonymously, forged identity headers never reach the app, and the
# path-matcher bypass table stays gated.
if should_run access; then
[ -n "$GO" ] && [ -x "$GO" ] || {
echo "access boot needs go to mint the owner assertion; none found (\$GO='${GO:-}')" >&2
Expand Down
8 changes: 8 additions & 0 deletions dev/cloud/test/catalog/whoami/manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ permissions:
internet: false
lan: false

# Path-scoped exposure (#415). The lane needs an app that declares public paths
# to prove the carve-out, and whoami is the right one: it echoes the request, so
# the assertions can see exactly what the app upstream received on each branch.
# The exact entry and the prefix entry are both declared, because "/v1/*" does
# NOT match "/v1" itself in Caddy and a real API app needs both.
access:
public_paths: ["/v1", "/v1/*"]

images:
traefik/whoami:v1.10.3:
digest: sha256:43a68d10b9dfcfc3ffbfe4dd42100dc9aeaf29b3a5636c856337a5940f1b4f1c
Expand Down
31 changes: 30 additions & 1 deletion dev/test-qemu/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ TEST_DIR="${REPO_ROOT}/dev/test-qemu"
WORK="${REPO_ROOT}/.dev/qemu"
EXTRA="${TEST_DIR}/mkosi.extra"
CANARY="${WORK}/.malmo-medium-ready"
CANARY_VERSION="v26" # bump when mkosi.conf changes require a clean rebuild
CANARY_VERSION="v27" # bump when mkosi.conf changes require a clean rebuild
PASSPHRASE_FILE="${TEST_DIR}/mkosi.passphrase" # LUKS recovery key (slice 0023); gitignored
IMAGE_OUT="${WORK}/malmo-medium.raw"
SSH_KEY="${WORK}/ssh-key"
Expand Down Expand Up @@ -208,8 +208,37 @@ mkdir -p "$EXTRA/etc/systemd/system" \
"$EXTRA/etc/ssh/sshd_config.d" \
"$EXTRA/etc/pam.d" \
"$EXTRA/etc/malmo/secrets" \
"$EXTRA/etc/docker" \
"$EXTRA/etc/systemd/system/docker.service.d" \
"$EXTRA/usr/local/bin"

# Container logging. Docker's daemon-wide log driver must be journald
# (LOGGING.md # Docker daemon uses the `journald` log driver): host-agent-real's
# per-app log tail runs `journalctl CONTAINER_NAME=<container>`
# (internal/hostagent/journalsource), and CONTAINER_NAME is set only by that
# driver. Under Docker's json-file default the match returns nothing and the
# dashboard's Logs tab waits forever, for every app. Kept byte-identical to the
# hosted lane's committed dev/cloud/mkosi.extra/etc/docker/daemon.json — both
# real profiles run the same host-agent binary against the same expectation.
cat > "$EXTRA/etc/docker/daemon.json" <<'EOF'
{
"log-driver": "journald"
}
EOF

# Docker is the deliberate exception to journald's per-unit rate limit
# (LOGGING.md # Tuning). journald enforces the limit against _SYSTEMD_UNIT, so
# with the driver above every container on the box shares ONE bucket attributed
# to docker.service; under the 10000-per-30s default a single chatty container
# silently starves every other container's lines. Disabled here rather than
# globally, so real system services keep their cap. Byte-identical to the
# hosted lane's dev/cloud/mkosi.extra/.../10-malmo-logging.conf.
cat > "$EXTRA/etc/systemd/system/docker.service.d/10-malmo-logging.conf" <<'EOF'
[Service]
LogRateLimitIntervalSec=0
LogRateLimitBurst=0
EOF

# Recovery keyfile baked at the production path STORAGE.md specifies
# (/etc/malmo/secrets/luks-recovery.key, mode 0400, root-owned). The
# first-boot enrollment service reads it via systemd-cryptenroll
Expand Down
Loading