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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ jobs:
- name: Run the one-shot secure demo over real HTTP
run: docker compose run --rm demo

- name: Reset to fresh state
# The demo's well-formed import mutates the secure container's fixtures; the
# comparison needs both apps to start from identical fresh state.
run: docker compose down --volumes --remove-orphans

- name: Run the vulnerable-vs-secure read comparison (opt-in)
run: ALLOW_VULNERABLE_DEMO=true docker compose --profile vulnerable run --rm compare

- name: Tear down
if: always()
run: docker compose down --volumes --remove-orphans
run: docker compose --profile vulnerable down --volumes --remove-orphans
69 changes: 53 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ archive. It is built for a mixed technical audience and runs entirely inside Doc
> outside its own container except that container's own `/etc/passwd`, and writes only
> inside a disposable in-container fixture tree.

This first milestone ships the **secure baseline** only. The deliberately *vulnerable*
contrast — the naive join, the broken sanitizer, and the Zip-Slip write — arrives in
later milestones and is always opt-in.
The demo now includes the deliberately *vulnerable* **read** contrast (the naive join and
the broken sanitizer), shown side by side with the secure app. The vulnerable app is
**opt-in** and hardened. The Zip-Slip **write** contrast arrives in a later milestone.

## The one idea

Expand All @@ -38,8 +38,9 @@ endpoint funnels through it.
1. **Resolve, then confine** — the primary fix, applied to reads *and* to every archive
entry before a single byte is written. (This milestone.)
2. **Blocklist filtering of `../` is not a boundary check** — stripping `../` once, or
inspecting a string before it is decoded, is defeated by `....//` and `%2e%2e%2f`.
(Shown against a deliberately broken "hardened" endpoint in a later milestone.)
inspecting a string before it is decoded, is defeated by `....//` (which collapses back
into `../` after one strip) and by percent-encoded `%2e%2e%2f` (only decoded *after* the
check). Shown against the vulnerable app's deliberately broken "hardened" endpoint.
3. **Addressing by opaque id removes the class entirely** — if no user-supplied path
component ever participates in locating a file, there is nothing to traverse. The
secure app already exposes this via `GET /documents/{document_id}`.
Expand Down Expand Up @@ -139,6 +140,39 @@ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
docker compose down --volumes
```

## The vulnerable read contrast (opt-in)

The vulnerable app demonstrates what the secure app refuses. Starting it takes **two
deliberate actions** — enabling the `vulnerable` Compose profile **and** setting
`ALLOW_VULNERABLE_DEMO=true` (the app refuses to boot without the acknowledgement). Its
container is hardened (non-root, all capabilities dropped, `no-new-privileges`, read-only
root filesystem) and has **no network egress** beyond its loopback-published port.

Run the side-by-side comparison (vulnerable vs secure, over real HTTP):

```sh
ALLOW_VULNERABLE_DEMO=true docker compose --profile vulnerable run --rm compare
docker compose --profile vulnerable down --volumes
```

The comparison walks the traversal ladder against both apps:

| Rung | Vulnerable app | Secure app |
|---|---|---|
| `../northwind-mills/statement-2026-07.txt` | another tenant's statement | generic `404` |
| `../../config/integration.key` | the integration key + `DEMO_SENTINEL` | generic `404` |
| `/etc/passwd` | the container's own `/etc/passwd` | generic `404` |
| `vault-link` (planted symlink) | out-of-root content | generic `404` |
| `....//....//config/integration.key` (hardened) | bypassed → integration key | generic `404` |
| `%2e%2e%2f…config%2fintegration.key` (hardened) | bypassed → integration key | generic `404` |

…and confirms the two apps return **identical** output for benign requests. For manual
exploration the vulnerable API is available on `127.0.0.1:8001`:

```sh
ALLOW_VULNERABLE_DEMO=true docker compose --profile vulnerable up vulnerable
```

## Expected outcomes

- The legitimate reads, import, and summary succeed and return only the caller's own data.
Expand All @@ -150,18 +184,21 @@ docker compose down --volumes

```text
src/boundless/
config.py identity.py fixtures.py safepath.py <- resolve-and-confine
archive.py catalog.py audit.py samples.py
scenario.py cli.py secure/app.py <- the secure FastAPI application
tests/ Dockerfile docker-compose.yml .github/workflows/ci.yml
config.py identity.py fixtures.py safepath.py <- resolve-and-confine
archive.py catalog.py audit.py samples.py webcommon.py
scenario.py comparison.py cli.py
secure/app.py <- the secure FastAPI application
vulnerable/app.py <- the intentionally vulnerable app (opt-in, read direction)
tests/ Dockerfile docker-compose.yml .github/workflows/ci.yml
```

## Safety boundary

The demonstration is wholly synthetic and local. It executes **no command**. In this
milestone it introduces **no vulnerable code path** at all. When later milestones add the
vulnerable contrast, starting it will require two deliberate actions, its container will be
hardened (non-root, all capabilities dropped, `no-new-privileges`, read-only root
filesystem, no network egress), and every write it performs will be confined to two
documented targets inside the disposable in-container fixture tree. Execution-reaching
write targets are out of scope by design.
The demonstration is wholly synthetic and local. It executes **no command**. The
vulnerable app introduced here is **read-only** in effect — it discloses files but writes,
deletes, and mutates nothing — and starting it requires two deliberate actions. Its
container is hardened (non-root, all capabilities dropped, `no-new-privileges`, read-only
root filesystem) with **no network egress** beyond its loopback port. The Zip-Slip
**write** contrast arrives in a later milestone; every write it performs will be confined
to two documented targets inside the disposable in-container fixture tree, and
execution-reaching write targets are out of scope by design.
85 changes: 82 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,88 @@ services:
retries: 30
start_period: 2s

# One-shot walkthrough runner: waits for the secure app, exercises it over real HTTP,
# prints a report, exits non-zero on any failed check. `docker compose run --rm demo`
# The intentionally vulnerable app. Two deliberate actions are required to start it:
# the `vulnerable` Compose profile AND ALLOW_VULNERABLE_DEMO=true (the app refuses to
# boot without the acknowledgement). It runs hardened with NO network egress — attached
# only to a bridge whose IP masquerade is disabled — while still reachable on loopback.
vulnerable:
build: .
image: boundless:local
profiles: ["vulnerable"]
init: true
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
environment:
ALLOW_VULNERABLE_DEMO: ${ALLOW_VULNERABLE_DEMO:-}
volumes:
- type: tmpfs
target: /data
tmpfs:
mode: 01777
- type: tmpfs
target: /tmp
tmpfs:
mode: 01777
networks:
- egress_blocked
ports:
- "127.0.0.1:8001:8001"
command:
- uvicorn
- boundless.vulnerable.app:create_vulnerable_app
- --factory
- --host
- "0.0.0.0"
- --port
- "8001"
healthcheck:
test:
- CMD
- python
- -c
- >-
import sys, urllib.request;
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8001/healthz').status == 200 else 1)
interval: 3s
timeout: 3s
retries: 30
start_period: 2s

# One-shot secure-baseline walkthrough. `docker compose run --rm demo`
demo:
image: boundless:local
build: .
depends_on:
secure:
condition: service_healthy
command: ["python", "-m", "boundless.cli", "--base-url", "http://secure:8000"]
command: ["python", "-m", "boundless.cli", "demo", "--base-url", "http://secure:8000"]
profiles: ["demo"]

# One-shot vulnerable-vs-secure read comparison. Requires the two opt-in actions:
# ALLOW_VULNERABLE_DEMO=true docker compose --profile vulnerable run --rm compare
compare:
image: boundless:local
build: .
profiles: ["vulnerable"]
depends_on:
secure:
condition: service_healthy
vulnerable:
condition: service_healthy
networks:
- default
- egress_blocked
command:
- python
- -m
- boundless.cli
- compare
- --secure-url
- http://secure:8000
- --vulnerable-url
- http://vulnerable:8001

# Lint, type-check, and test through the same image. Mounts the working tree so it
# verifies current source without a rebuild; CI runs the identical command.
verify:
Expand All @@ -61,3 +132,11 @@ services:
BOUNDLESS_DATA_ROOT: /tmp/data
command: ["sh", "scripts/verify.sh"]
profiles: ["verify"]

networks:
# A bridge with IP masquerade disabled: containers on it cannot egress to the internet
# (no SNAT), but their loopback-published ports remain reachable from the host.
egress_blocked:
driver: bridge
driver_opts:
com.docker.network.bridge.enable_ip_masquerade: "false"
93 changes: 75 additions & 18 deletions src/boundless/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Command-line demo runner for the secure baseline.
"""Command-line demo runner.

Usage (inside the Compose network)::
Two subcommands, both driven over real HTTP:

python -m boundless.cli --base-url http://secure:8000
- ``demo`` exercises the secure baseline (secure + legitimate behaviour).
- ``compare`` runs the traversal ladder against the vulnerable and secure apps side by
side, printing the contrast.

It waits for the target to become healthy, runs the secure + legitimate walkthrough over
real HTTP, prints a readable report, and exits non-zero if any step failed. Later slices
extend this into the full vulnerable/secure comparison; here it exercises the secure app.
Each waits for its target(s) to become healthy, prints a readable report, and exits
non-zero if any check failed. The scenario/comparison engines are pure functions over
``httpx`` clients, so tests drive them exactly as the CLI does.
"""

from __future__ import annotations
Expand All @@ -18,7 +20,10 @@

import httpx

from .scenario import Check, all_passed, run_secure_baseline
from .comparison import Row, run_comparison
from .comparison import all_passed as comparison_passed
from .scenario import Check, run_secure_baseline
from .scenario import all_passed as scenario_passed


def _wait_for_health(base_url: str, timeout: float) -> bool:
Expand All @@ -34,7 +39,7 @@ def _wait_for_health(base_url: str, timeout: float) -> bool:
return False


def _print_report(checks: list[Check]) -> None:
def _print_checks(checks: list[Check]) -> None:
group_titles = {
"legitimate": "Legitimate behaviour (authenticated, benign)",
"secure-read": "Secure retrieval — every unsafe name is an indistinguishable 404",
Expand All @@ -53,30 +58,82 @@ def _print_report(checks: list[Check]) -> None:
print(f" observed : {check.observed}")


def main(argv: Sequence[str] | None = None) -> int:
"""Entry point; returns a process exit code."""
parser = argparse.ArgumentParser(description="boundless secure-baseline demo runner")
parser.add_argument("--base-url", default="http://secure:8000")
parser.add_argument("--health-timeout", type=float, default=30.0)
args = parser.parse_args(argv)
def _print_rows(rows: list[Row]) -> None:
group_titles = {
"traversal": "Traversal ladder — vulnerable crosses, secure refuses",
"parity": "Legitimate parity — both apps agree",
}
current = ""
for row in rows:
if row.group != current:
current = row.group
print(f"\n== {group_titles.get(current, current)} ==")
mark = "PASS" if row.passed else "FAIL"
print(f" [{mark}] {row.name}")
print(f" submitted : {row.submitted}")
print(f" secure : {row.secure_observed}")
print(f" vulnerable : {row.vulnerable_observed}")
print(f" verdict : {row.verdict}")


def _run_demo(args: argparse.Namespace) -> int:
print(f"boundless demo — target {args.base_url}")
if not _wait_for_health(args.base_url, args.health_timeout):
print(f"error: {args.base_url} did not become healthy in time", file=sys.stderr)
return 2

with httpx.Client(base_url=args.base_url, timeout=10.0) as client:
checks = run_secure_baseline(client)

_print_report(checks)
_print_checks(checks)
passed = sum(1 for c in checks if c.passed)
print(f"\n{passed}/{len(checks)} checks passed")
if all_passed(checks):
if scenario_passed(checks):
print("RESULT: secure baseline behaves as specified.")
return 0
print("RESULT: one or more checks FAILED.", file=sys.stderr)
return 1


def _run_compare(args: argparse.Namespace) -> int:
print(f"boundless compare — secure {args.secure_url} vs vulnerable {args.vulnerable_url}")
for url in (args.secure_url, args.vulnerable_url):
if not _wait_for_health(url, args.health_timeout):
print(f"error: {url} did not become healthy in time", file=sys.stderr)
return 2
with (
httpx.Client(base_url=args.secure_url, timeout=10.0) as secure,
httpx.Client(base_url=args.vulnerable_url, timeout=10.0) as vulnerable,
):
rows = run_comparison(secure, vulnerable)
_print_rows(rows)
passed = sum(1 for r in rows if r.passed)
print(f"\n{passed}/{len(rows)} rows passed")
if comparison_passed(rows):
print("RESULT: vulnerable app crosses the boundary; secure app refuses; parity holds.")
return 0
print("RESULT: one or more comparison rows FAILED.", file=sys.stderr)
return 1


def main(argv: Sequence[str] | None = None) -> int:
"""Entry point; returns a process exit code."""
parser = argparse.ArgumentParser(description="boundless demo runner")
sub = parser.add_subparsers(dest="command", required=True)

demo = sub.add_parser("demo", help="exercise the secure baseline")
demo.add_argument("--base-url", default="http://secure:8000")
demo.add_argument("--health-timeout", type=float, default=30.0)
demo.set_defaults(func=_run_demo)

compare = sub.add_parser("compare", help="vulnerable vs secure read comparison")
compare.add_argument("--secure-url", default="http://secure:8000")
compare.add_argument("--vulnerable-url", default="http://vulnerable:8001")
compare.add_argument("--health-timeout", type=float, default=30.0)
compare.set_defaults(func=_run_compare)

args = parser.parse_args(argv)
result: int = args.func(args)
return result


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading