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
140 changes: 140 additions & 0 deletions control-plane/tests/test_caddy_hardening.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""What the public site must send, and how it answers a path that is not part of the app (deploy/Caddyfile).

A test that asserts a guard is there is worth more than one that asserts the happy path works: each of these
failed in the 2026-11-01 test run (no security headers; every unknown path, including /.env, answered 200
with the app shell). They read the file, so they run everywhere; the last one also asks Caddy itself when it
is installed.
"""

import re
import shutil
import subprocess
from datetime import UTC, datetime
from fnmatch import fnmatch
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[2]
CADDYFILE = ROOT / "deploy" / "Caddyfile"
PUBLIC = ROOT / "frontend" / "public"


def _text() -> str:
return CADDYFILE.read_text()


def _site_block() -> str:
match = re.search(r"(?ms)^mergit\.site, app\.mergit\.site \{.*?^\}", _text())
assert match, "the mergit.site block is missing"
return match.group(0)


def _snippet() -> str:
match = re.search(r"(?ms)^\(site_headers\) \{.*?^\}", _text())
assert match, "the site_headers snippet is missing"
return match.group(0)


@pytest.mark.parametrize(
"header",
[
"Strict-Transport-Security",
"X-Frame-Options",
"X-Content-Type-Options",
"Referrer-Policy",
"Permissions-Policy",
],
)
def test_every_response_of_the_site_carries_the_baseline_headers(header: str) -> None:
# In the site block itself, outside the handle blocks, so the API's responses get them too.
outside = re.sub(r"(?ms)^\thandle[^\n]*\{.*?^\t\}", "", _site_block())
assert re.search(rf"(?m)^\t\t{header} ", outside), f"{header} is not set on every response"


def test_the_page_cannot_be_framed() -> None:
assert re.search(r'X-Frame-Options "?DENY"?', _site_block())


def test_hsts_is_not_applied_to_subdomains_or_preloaded() -> None:
hsts = re.search(r"Strict-Transport-Security \"([^\"]+)\"", _site_block())
assert hsts, "no HSTS"
assert "includeSubDomains" not in hsts.group(1) and "preload" not in hsts.group(1)
assert int(re.search(r"max-age=(\d+)", hsts.group(1)).group(1)) >= 31536000 # pyright: ignore[reportOptionalMemberAccess]


def test_the_csp_is_report_only_until_someone_has_watched_it_on_a_deployed_page() -> None:
snippet = _snippet()
assert "Content-Security-Policy-Report-Only" in snippet
assert not re.search(r"(?m)^\s*header\s+Content-Security-Policy\s", snippet), (
"an enforcing CSP was added: it can break sign-in or the wallet if it was not watched first"
)
for must in ("default-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "base-uri 'self'"):
assert must in snippet


def test_the_page_is_not_cached_but_assets_still_are() -> None:
snippet = _snippet()
assert re.search(r'header @shell Cache-Control "no-cache"', snippet)
assert re.search(r"@shell not path /assets/\*", snippet)
assert "immutable" in snippet


def test_an_unknown_path_is_a_404_not_the_app_shell() -> None:
snippet = _snippet()
assert "try_files {path} /index.html" not in snippet, "every unknown path would answer 200 with the app"
assert re.search(r"(?m)^\trewrite @spa /index\.html$", snippet)


def test_the_apps_own_routes_still_reach_the_shell() -> None:
match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet())
assert match, "no @spa route list"
patterns = match.group(1).split()
# The router's top-level paths (frontend/src/app/paths.ts) and a few real deep links.
for path in (
"/",
"/signin",
"/demo",
"/demo/runs",
"/app",
"/app/runs/new",
"/app/proofs/abc",
"/app/settings",
):
assert any(fnmatch(path, p) for p in patterns), f"{path} would 404 instead of opening the app"
for path in ("/.env", "/.git/config", "/nope-404", "/sitemap.xml", "/wp-login.php", "/v0/anything"):
assert not any(fnmatch(path, p) for p in patterns), f"{path} must not fall back to the app"


def test_the_router_has_no_top_level_route_the_site_would_404() -> None:
paths = (ROOT / "frontend" / "src" / "app" / "paths.ts").read_text()
top_level = {
m.group(1) for m in re.finditer(r'(?m)^\s+(?:landing|signIn|demo|liveApp):\s*"(/[^"]*)"', paths)
}
match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet())
assert match and top_level
patterns = match.group(1).split()
missing = {p for p in top_level if not any(fnmatch(p, pat) for pat in patterns)}
assert missing == set(), f"the app has routes the Caddyfile would answer with 404: {sorted(missing)}"


def test_robots_and_security_txt_exist_and_security_txt_has_not_expired() -> None:
robots = (PUBLIC / "robots.txt").read_text()
assert "User-agent: *" in robots and "Disallow: /app/" in robots
security = (PUBLIC / ".well-known" / "security.txt").read_text()
assert re.search(r"(?m)^Contact: \S+", security)
expires = re.search(r"(?m)^Expires: (\S+)$", security)
assert expires, "security.txt needs an Expires line (RFC 9116)"
when = datetime.fromisoformat(expires.group(1).replace("Z", "+00:00"))
assert when > datetime.now(UTC), "security.txt has expired: renew its Expires date"


@pytest.mark.skipif(shutil.which("caddy") is None, reason="caddy is not installed here")
def test_caddy_accepts_the_file() -> None:
done = subprocess.run(
["caddy", "validate", "--config", str(CADDYFILE), "--adapter", "caddyfile"],
capture_output=True,
text=True,
check=False,
)
assert done.returncode == 0, done.stderr[-500:]
23 changes: 22 additions & 1 deletion control-plane/tests/test_caddy_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
# Served to the gateway and the worker over the compose network, and deliberately not proxied.
INTERNAL = ("/v0/internal", "/v0/chain-events")

# Browser routes that are withheld from the public origin until they are safe to expose: DELETE /v0/account
# removes the account, and what a deletion leaves behind and the once-per-person free credits are still open
# (accounts.delete_account). Move a path out of this list in the same change that adds it to the Caddyfile.
WITHHELD = ("/v0/account",)


def _proxied_patterns() -> list[str]:
text = CADDYFILE.read_text()
Expand All @@ -51,7 +56,7 @@ def _browser_paths() -> list[str]:
paths = {
re.sub(r"\{[^}]+\}", "x", r.path) for router in routers for r in router.routes if hasattr(r, "path")
} # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType,reportUnknownArgumentType]
return sorted(p for p in paths if not p.startswith(INTERNAL))
return sorted(p for p in paths if not p.startswith(INTERNAL) and p not in WITHHELD)


def test_every_browser_route_is_proxied_to_the_api() -> None:
Expand All @@ -66,5 +71,21 @@ def test_the_internal_routes_stay_off_the_public_origin() -> None:
assert not any(fnmatch(path, pat) for pat in patterns), f"{path} must not be proxied"


def test_withheld_routes_stay_off_the_public_origin() -> None:
patterns = _proxied_patterns()
for path in WITHHELD:
assert not any(fnmatch(path, pat) for pat in patterns), f"{path} is withheld and must not be proxied"


def test_a_withheld_route_is_a_real_route() -> None:
"""A path left in WITHHELD after its route was removed or renamed would hide nothing and mislead."""
paths = {
r.path # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType]
for r in routes_account.router.routes
if hasattr(r, "path")
}
assert set(WITHHELD) <= paths


def test_the_check_would_notice_a_route_it_does_not_know() -> None:
assert not any(fnmatch("/v0/brand-new/thing", pat) for pat in _proxied_patterns())
32 changes: 25 additions & 7 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@

(site_headers) {
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
}
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The apps use createBrowserRouter, so any unknown path must fall back to index.html.
try_files {path} /index.html
# The page itself is never cached: a new deploy has new asset names, and a stale index.html would point at old ones.
@shell not path /assets/*
header @shell Cache-Control "no-cache"
# Report-only: it shows in the browser console what would be blocked, without blocking. Watch it on a deployed
# page (sign in, open a run, the wallet picker) and fix every violation before turning it into an enforcing
# Content-Security-Policy. Enforcing it blind could break sign-in or the wallet. Clickjacking is covered
# already by X-Frame-Options below.
header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; font-src 'self' data:; connect-src 'self' https://gateway.mergit.site https://store.mergit.site https://sepolia.base.org https://api.github.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"
# The app is a single-page app, so its own routes fall back to index.html. Anything else that is not a file is a
# real 404 (not the app shell with a 200, which scanners flag and search engines index as soft 404s).
@spa path / /signin /signin/* /demo /demo/* /app /app/*
rewrite @spa /index.html
}

# The site: the frontend on live Base Sepolia data, and the orchestration API on the same origin. It is served on both
Expand All @@ -27,7 +33,19 @@
# send people back to, since each allows one callback address), EXTRA_DOMAINS=mergit.site (wallet sign-in is accepted
# from both) and COOKIE_DOMAIN=mergit.site (the session cookie is shared, so signing in on one signs in on the other).
mergit.site, app.mergit.site {
@api path /v0/auth/* /v0/account /v0/account/* /v0/wallet-interest /v0/plans /v0/plans/* /v0/agents /v0/agents/*
# The bare /v0/account is not listed on purpose: it is DELETE /v0/account, deleting the account. It stays off the public
# origin until the gaps in what a deletion removes and in the once-per-person free credits are decided (see
# accounts.delete_account). /v0/account/* below is every other account route.
@api path /v0/auth/* /v0/account/* /v0/wallet-interest /v0/plans /v0/plans/* /v0/agents /v0/agents/*
# On every response, the page's and the API's. HSTS has no includeSubDomains or preload: other names under this
# domain are not all known to be HTTPS-only, and a preload entry cannot be taken back quickly.
header {
Strict-Transport-Security "max-age=31536000"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
}
handle @api {
reverse_proxy api:8000
}
Expand Down
109 changes: 109 additions & 0 deletions deploy/e2e/false_receipt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# End-to-end check 2 (deploy/e2e/false_receipt.md), driven without the planner: a dishonest operator who holds the
# platform's upload and event tokens commits a receipt that says the check passed when it failed. Expect the chain to
# accept the commitment (the signature is genuine), the validator to record `invalid`, and the plan's money to come back.
#
# deploy/e2e/false_receipt.sh <requester-address> run it where the secret files below exist
# DRY_RUN=1 deploy/e2e/false_receipt.sh <requester-address> build and check the false receipt locally, send nothing
#
# <requester-address> is the plan's requester. Use a fresh address nobody holds a key for (`cast wallet new` and keep only
# the address): the plan is funded from free credits, which are per address.
#
# Reads, and never prints: EVENTS_TOKEN, STORE_WRITE_TOKEN and RPC_URL from deploy/secrets/testnet.env, and the agent's
# runtime key from ../mergit-contracts/.env.services (MERGIT_AGENT_KEY_<agent>). For a dry run only, AGENT_KEY in the
# environment replaces the file. Optional: AGENT_ID (default 1), GATEWAY_URL, STORE_URL, WAIT_S (default 420).
# Needs cargo, cast, curl and python3. The forged-receipt builder is a test fixture: no service or image enables it.
set -euo pipefail
set +x
cd "$(dirname "$0")/../.."
[ $# -eq 1 ] || { echo "usage: $0 <requester-address>" >&2; exit 2; }
requester=$1
[[ $requester =~ ^0x[0-9a-fA-F]{40}$ ]] || { echo "requester must be an address" >&2; exit 2; }
agent=${AGENT_ID:-1}
gateway=${GATEWAY_URL:-https://gateway.mergit.site}
store=${STORE_URL:-https://store.mergit.site}
wait_s=${WAIT_S:-420}
deployment=../mergit-contracts/deployments/base-sepolia.json
for tool in cargo cast curl python3; do command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }; done
[ -e "$deployment" ] || { echo "$deployment missing" >&2; exit 1; }
field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$deployment" "$1"; }
escrow=$(field escrow); registry=$(field receipt_registry); chain_id=$(field chain_id)

# Secrets are read into variables of this process only; nothing below echoes them.
if [ -n "${DRY_RUN:-}" ]; then
key=${AGENT_KEY:?DRY_RUN needs AGENT_KEY (any throwaway key) in the environment}
events_token=dry-run; store_token=dry-run; rpc=
else
[ -e deploy/secrets/testnet.env ] || { echo "deploy/secrets/testnet.env missing" >&2; exit 1; }
[ -e ../mergit-contracts/.env.services ] || { echo "../mergit-contracts/.env.services missing" >&2; exit 1; }
events_token=$(set -a; . deploy/secrets/testnet.env; echo "${EVENTS_TOKEN:-}")
store_token=$(set -a; . deploy/secrets/testnet.env; echo "${STORE_WRITE_TOKEN:-}")
rpc=$(set -a; . deploy/secrets/testnet.env; echo "${RPC_URL:-}")
key=$(set -a; . ../mergit-contracts/.env.services; v="MERGIT_AGENT_KEY_$agent"; echo "${!v:-}")
[ -n "$events_token" ] && [ -n "$store_token" ] && [ -n "$rpc" ] || { echo "a token or RPC_URL is empty in testnet.env" >&2; exit 1; }
[ -n "$key" ] || { echo "MERGIT_AGENT_KEY_$agent is not in .env.services" >&2; exit 1; }
fi

uuid=$(cat /proc/sys/kernel/random/uuid)
plan_id=$(cast keccak "$uuid")
task_id=$(cast keccak "$(cast abi-encode --packed 'f(bytes32,uint32,uint32)' "$plan_id" 0 1)")
out=$(mktemp -d)
echo "plan_uuid=$uuid"; echo "plan_id=$plan_id"; echo "task_id=$task_id"; echo "files: $out"

echo "1/6 building the false receipt (this compiles on first use)"
cargo run -q -p mergit-receipt --features forgery-fixture --example false_receipt -- \
--task "$task_id" --plan "$plan_id" --agent "$agent" --requester "$requester" \
--key "$key" --chain-id "$chain_id" --registry "$registry" --out "$out"
echo "2/6 the verifier must call it invalid (PHI_RESULT_CONTRADICTED)"
verdict=$(cargo run -q -p mergit-verifier -- "$out/bundle.json" --chain-id "$chain_id" --registry "$registry" || true)
echo "$verdict" | head -5
echo "$verdict" | grep -q "PHI_RESULT_CONTRADICTED" || { echo "FAIL: the verifier did not flag the forgery" >&2; exit 1; }
if [ -n "${DRY_RUN:-}" ]; then echo "dry run: stopping before anything is sent"; exit 0; fi

phi_hash=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["receipt"]["header"]["phi_hash"])' "$out/bundle.json")
now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
post_event() { # type payload-json -> HTTP status
local body; body=$(python3 -c 'import json,sys,uuid; print(json.dumps({"event_id": str(uuid.uuid4()), "type": sys.argv[1], "at": sys.argv[2], "payload": json.loads(sys.argv[3])}))' "$1" "$(now)" "$2")
curl -sS -o /dev/null -w '%{http_code}' -X POST "$gateway/v0/events" -H "Authorization: Bearer $events_token" \
-H "Idempotency-Key: $(cat /proc/sys/kernel/random/uuid)" -H 'content-type: application/json' --data "$body"
}

echo "3/6 funding the plan from free credits"
code=$(post_event plan.approved "{\"plan_id\":\"$plan_id\",\"plan_uuid\":\"$uuid\",\"requester\":\"$requester\",\"total_budget_usdc_units\":\"1000000\",\"funding\":\"credits\"}")
echo " plan.approved -> HTTP $code"; [ "$code" = 200 ] || { echo "FAIL: the gateway refused the plan" >&2; exit 1; }

echo "4/6 locking one task for agent $agent (the receipt's own check, so the Φ hash matches)"
deadline=$(( $(date +%s) + 1800 ))
for attempt in $(seq 1 12); do
code=$(post_event task.dispatch_requested "{\"task_id\":\"$task_id\",\"plan_id\":\"$plan_id\",\"index\":0,\"attempt\":1,\"agent_id\":\"$agent\",\"amount_usdc_units\":\"1000000\",\"category\":\"test-repair\",\"phi_hash\":\"$phi_hash\",\"deadline_unix\":$deadline}")
echo " task.dispatch_requested -> HTTP $code"
[ "$code" = 200 ] && break
sleep 10
done
[ "$code" = 200 ] || { echo "FAIL: the task was never locked" >&2; exit 1; }

echo "5/6 uploading the bundle, then announcing the receipt, as a worker would"
curl -sS -o /dev/null -w ' PUT /v0/bundles -> HTTP %{http_code}\n' -X PUT "$store/v0/bundles" -H "Authorization: Bearer $store_token" \
-H 'content-type: application/json' --data @"$out/bundle.json"
curl -sS -o /dev/null -w ' receipt.sealed -> HTTP %{http_code}\n' -X POST "$gateway/v0/events" -H "Authorization: Bearer $events_token" \
-H "Idempotency-Key: $(cat /proc/sys/kernel/random/uuid)" -H 'content-type: application/json' --data @"$out/receipt-sealed.json"

echo "6/6 waiting up to ${wait_s}s for the escrow to settle task $task_id"
states=(None Locked Released Returned); verdicts=(None Pass Fail Invalid)
end=$(( $(date +%s) + wait_s ))
while [ "$(date +%s)" -lt "$end" ]; do
read -r -d '' -a t < <(cast call "$escrow" 'taskOf(bytes32)(bytes32,uint256,address,uint256,uint64,uint8,uint8,uint64)' "$task_id" --rpc-url "$rpc" | awk '{print $1}'; printf '\0') || true
state=${t[5]:-0}; verdict=${t[6]:-0}
echo " escrow state=${states[$state]:-?} verdict=${verdicts[$verdict]:-?}"
if [ "$state" = 3 ]; then
if [ "$verdict" = 3 ]; then
echo "PASS: the false receipt was committed, judged Invalid, and the task was Returned."
echo "Record the transactions from BaseScan: https://sepolia.basescan.org/address/$escrow#events (filter by task $task_id)"
exit 0
fi
echo "FAIL: the task was returned with verdict ${verdicts[$verdict]:-?}, not Invalid" >&2; exit 1
fi
[ "$state" = 2 ] && { echo "FAIL: the task was RELEASED to the agent: the false receipt was paid" >&2; exit 1; }
sleep 15
done
echo "FAIL: not settled in ${wait_s}s (is the validator running?)" >&2; exit 1
Loading
Loading