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
13 changes: 13 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.git
.github
**/__pycache__
*.py[cod]
.pytest_cache
.mypy_cache
.ruff_cache
.venv
venv
*.egg-info
build
dist
.DS_Store
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: verify

on:
push:
branches: [main]
pull_request:

jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Build the demo image
run: docker compose build

- name: Lint, type-check, and test (the same Compose verification boundary)
run: docker compose run --rm verify

- name: Run the one-shot secure demo over real HTTP
run: docker compose run --rm demo

- name: Tear down
if: always()
run: docker compose down --volumes --remove-orphans
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
*.egg-info/
build/
dist/
.DS_Store
39 changes: 39 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
#
# One image serves every Compose service: the hardened secure runtime, the one-shot demo
# runner, and the verify (lint/type/test) job. It is a local development and teaching
# image only — never deploy it.
FROM python:3.13-slim-bookworm

# Pinned uv for fast, reproducible installs straight from the committed lockfile.
COPY --from=ghcr.io/astral-sh/uv:0.12.4 /uv /uvx /usr/local/bin/

ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv \
PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/src \
BOUNDLESS_DATA_ROOT=/data

WORKDIR /app

# Install third-party dependencies only (not the project). Runtime services then get the
# source via COPY, and the verify service via a bind mount, both through PYTHONPATH.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

COPY src/ ./src/
COPY tests/ ./tests/
COPY scripts/ ./scripts/

# Non-root runtime user. /data is a writable tmpfs mount, recreated on every start.
RUN useradd --create-home --uid 10001 demo \
&& mkdir -p /data \
&& chown -R demo:demo /data /app
USER demo

EXPOSE 8000
CMD ["uvicorn", "boundless.secure.app:create_secure_app", "--factory", \
"--host", "0.0.0.0", "--port", "8000"]
167 changes: 166 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,167 @@
# boundless
Private implementation repository for boundless.

**A container-only teaching demo for path traversal — the safe way first.**

`boundless` is a small, fully fictional, **local-only** project that shows how a service
should handle a user-supplied filename: **resolve the candidate path, then confine it to
its base directory**, applied identically to reading a document and to importing an
archive. It is built for a mixed technical audience and runs entirely inside Docker.

> ⚠️ **This is educational code. Do not deploy it.** Every organization, user, statement,
> token, and "secret" here is synthetic. The demo executes no command, reads nothing
> 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 one idea

Joining a user-supplied name to a base directory proves **nothing** about where the
joined path *lands*:

```text
base = /data/archive/aurora-freight
name = ../../config/integration.key
join(base, name) = /data/archive/aurora-freight/../../config/integration.key
= /data/config/integration.key # outside the base!
```

The only reliable question is: **after full resolution — `.` and `..` collapsed, symlinks
followed — is the path still inside the resolved base?** That is exactly what
[`boundless.safepath.confine`](src/boundless/safepath.py) asks, and every name-accepting
endpoint funnels through it.

## Three lessons

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.)
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}`.

## Terminology

The vulnerability class is **Path Traversal** (a.k.a. **directory traversal**, the
**`../` / dot-dot-slash attack**, and — in its archive-extraction form — **Zip Slip**).

| Term | Maps to |
|---|---|
| OWASP | **A01:2021 — Broken Access Control** |
| CWE-22 | Improper limitation of a pathname to a restricted directory |
| CWE-23 | Relative path traversal (`../`) |
| CWE-36 | Absolute path traversal (`/etc/passwd`) |
| CWE-59 | Link following (a symlink whose target is outside the base) |

## The fictional model

A supplier-facing **statement archive** for several tenant organizations of a shared SaaS.
Each tenant has users and a per-tenant archive directory of monthly statements, and can
import a `.zip` of statements. Two files sit **outside** every tenant directory but inside
the data tree: a fictional **integration key** (carrying a `DEMO_SENTINEL` marker) and a
**branding configuration** whose footer the statement summary reads at request time. One
tenant directory contains a planted **symlink** whose target is outside the archive root.

```text
/data/
archive/ <- the common archive root
aurora-freight/ <- a tenant base directory
statement-2026-05.txt
statement-2026-06.txt
statement-2026-07.txt
vault-link -> ../../config/integration.key <- planted symlink (CWE-59)
northwind-mills/ ...
borealis-supply/ ...
config/ <- outside the archive root
integration.key <- fictional; carries DEMO_SENTINEL
branding.conf <- footer read by the statement summary
```

The fixtures are deterministic and are **recreated fresh on every container start**.

## API (secure app)

All endpoints authenticate with an unmistakably demo-only static bearer token that maps
to one user, hence one tenant.

| Method & path | Purpose |
|---|---|
| `GET /documents?name=…` | Retrieve a document by name — resolved and confined before opening. |
| `GET /documents/{document_id}` | Retrieve by opaque catalog id — no path component accepted. |
| `POST /documents/import` | Import a `.zip`, all-or-nothing, every entry confined first. |
| `GET /statements/summary` | Tenant summary; footer read from `branding.conf` at request time. |
| `GET /healthz` | Readiness. |

Any traversing, absolute, percent-encoded, double-encoded, or symlink-escaping name — and
any well-formed-but-missing name — returns the **same generic `404 Not Found`**, so no
response distinguishes "outside the base" from "does not exist". A traversing, absolute, or
link archive entry causes the **whole** import to fail with a generic `400`, writing no
entry. Each security rejection emits exactly one generic structured audit event to stdout
that names the actor, tenant, operation, and outcome — and never the submitted name, the
base directory, an absolute path, a token, or a secret.

## Requirements

- Docker with the Compose plugin. **Nothing else** — no local Python, no `uv`. Python,
dependencies, `pytest`, `ruff`, and `mypy` all run inside the container.

## Run it

**One-shot walkthrough** (brings up the secure app, exercises the secure + legitimate
behaviour over real localhost HTTP, prints a report, exits non-zero on any failure):

```sh
docker compose run --rm demo
docker compose down --volumes # clean up the background secure service
```

**Verification** (lint, type-check, and the full test suite — the same boundary CI runs):

```sh
docker compose run --rm verify
```

**Explore the API manually** (long-running secure service on `127.0.0.1:8000`, loopback
only; interactive OpenAPI docs at `/docs`):

```sh
docker compose up secure
# then, in another shell:
TOKEN=demo-token-aurora-uma-NOT-A-REAL-SECRET
curl -s -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8000/documents?name=statement-2026-07.txt' # 200, own statement
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
'http://127.0.0.1:8000/documents?name=../../config/integration.key' # 404, confined
docker compose down --volumes
```

## Expected outcomes

- The legitimate reads, import, and summary succeed and return only the caller's own data.
- Every unsafe name is an indistinguishable `404`; every unsafe archive is a whole-archive
`400` with nothing written; the fixture tree is byte-for-byte unchanged after each.
- `docker compose run --rm verify` is green (Ruff, mypy, pytest), locally and in CI.

## Layout

```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
```

## 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.
63 changes: 63 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: boundless

services:
# The secure application is the default long-running service, on loopback only.
secure:
build: .
image: boundless:local
init: true
read_only: true
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
# The disposable fixture tree is the only writable location. tmpfs must be
# world-writable (mode 1777) so the non-root user can recreate it on every start.
volumes:
- type: tmpfs
target: /data
tmpfs:
mode: 01777
- type: tmpfs
target: /tmp
tmpfs:
mode: 01777
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test:
- CMD
- python
- -c
- >-
import sys, urllib.request;
sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)
interval: 3s
timeout: 3s
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`
demo:
image: boundless:local
build: .
depends_on:
secure:
condition: service_healthy
command: ["python", "-m", "boundless.cli", "--base-url", "http://secure:8000"]
profiles: ["demo"]

# 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:
image: boundless:local
build: .
working_dir: /work
volumes:
- .:/work
environment:
PYTHONPATH: /work/src
RUFF_CACHE_DIR: /tmp/ruff
MYPY_CACHE_DIR: /tmp/mypy
BOUNDLESS_DATA_ROOT: /tmp/data
command: ["sh", "scripts/verify.sh"]
profiles: ["verify"]
51 changes: 51 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
[project]
name = "boundless"
version = "0.1.0"
description = "A container-only path-traversal (CWE-22 / Zip Slip) teaching demo: join-versus-resolve, done safely."
readme = "README.md"
requires-python = ">=3.13"
authors = [{ name = "boundless demo authors" }]
keywords = ["security", "education", "path-traversal", "cwe-22", "zip-slip"]
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.30",
"httpx>=0.27",
"python-multipart>=0.0.9",
]

[dependency-groups]
dev = [
"pytest>=8.2",
"ruff>=0.6",
"mypy>=1.11",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/boundless"]

[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "RUF"]

[tool.ruff.lint.per-file-ignores]
# Tests import a top-level helper module resolved via the tests directory on sys.path.
"tests/*" = ["INP001"]

[tool.mypy]
python_version = "3.13"
strict = true
namespace_packages = true
explicit_package_bases = true
mypy_path = ["src", "tests"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
18 changes: 18 additions & 0 deletions scripts/verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
# The single verification boundary: lint, format check, type check, tests.
# Run inside the container via `docker compose run --rm verify`; CI runs the same thing.
set -eu

echo "== ruff check =="
ruff check src tests

echo "== ruff format --check =="
ruff format --check src tests

echo "== mypy =="
mypy src tests

echo "== pytest =="
pytest

echo "== OK =="
Loading
Loading