Initial commit: Imperva WAF demo lab

Flight-booking app scaffolded from the source running on the GCP host,
plus the four-screen frontend and three OWASP attack endpoints (SQLi,
reflected XSS, path traversal) built and locally verified against a
live uvicorn/vite dev pair. Includes deploy.sh (local build -> ship
over ssh -> swap on host) and docs/ carried over from the handoff.
This commit is contained in:
matt committed 2026-07-29 11:08:56 -07:00
commit 4d60fdd139
28 files changed
+2609

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
# Architecture
## Overview
Single GCP `e2-micro` running three rootless Podman containers under user `matt`.
Imperva Cloud WAF sits in front of everything. The origin is network-isolated so that
the only things that can reach the app are Imperva's edge nodes.
```
Visitor ──TLS──▶ Imperva Cloud WAF ──TLS (re-encrypt)──▶ Caddy :443 ──HTTP──▶ backend/frontend
(terminates, (self-signed cert)
inspects,
re-encrypts)
```
## The two TLS legs (this is the crux of the design)
There are two independent TLS connections, and they use different certificates. Mixing
them up is the single easiest way to break this setup.
**Leg 1 — Visitor → Imperva (edge).**
Imperva presents a publicly-trusted certificate for `flytoouter.space` to browsers.
This is Imperva's *managed* cert (auto-provisioned by Imperva). It must be publicly
trusted or every visitor gets a browser warning. We do **not** touch this with our
self-signed cert.
**Leg 2 — Imperva → origin (re-encryption).**
Imperva opens a *second* TLS connection back to the GCP origin and inspects/re-encrypts
before forwarding. Caddy serves this leg on `:443` with a **self-signed certificate**.
Imperva is the only client that ever sees this cert, so self-signed is fine and
produces no visitor-facing warnings.
DNS for `flytoouter.space` must point at **Imperva**, not at `35.203.147.44`. If it
resolves straight to the origin, browsers try to negotiate TLS with the self-signed
origin cert and fail.
## Request flow through the origin
Caddy (`caddy-proxy`) is the only container bound to a public port. It terminates the
re-encryption TLS and reverse-proxies over plain HTTP to the app containers via the
`host.containers.internal` alias on the default rootless bridge network:
- `handle /api/*` → `host.containers.internal:8000` (FastAPI backend)
- `handle /*` → `host.containers.internal:8080` (Vue frontend)
`handle` (not `handle_path`) is used so the `/api` prefix is preserved for the backend
routes (`/api/v1/users`, etc.).
## Container stack
| Container | Image | Port | Role |
|----------------|------------------------------------|-------------|--------------------------------------------------|
| `caddy-proxy` | `docker.io/library/caddy:2-alpine` | `443:443` | TLS termination (re-encryption leg) + reverse proxy |
| `backend-api` | custom Python 3.11-slim (FastAPI) | `8000:8000` | REST API + local SQLite `demo.db` |
| `frontend-vue` | custom multi-stage (Node build → Nginx) | `8080:8080` | Vue SPA (flight booking UI) |
Caddy currently serves only `:443`. There is intentionally no `:80` site block (see
DECISIONS.md — a stray `:80` block under `auto_https off` caused the `ambiguous site
definition` failure). If Imperva needs an HTTP health check, an `:80` block can be
added back as a separate, non-conflicting listener.
## Network / edge security
- **Origin target tag:** `imperva-origin`.
- **`allow-imperva-waf`:** HTTP/HTTPS ingress restricted to Imperva's published IPv4
CIDR blocks (e.g. `199.83.128.0/21`, `198.143.32.0/19`, …). Confirm `tcp:443` is in
the allowed ports since the origin path is now 443-only.
- **`allow-home-ssh`:** SSH (22) locked to the operator's static residential IP.
- All default open (`0.0.0.0/0`) rules purged.
Because the origin only answers Imperva CIDRs, the deliberately-vulnerable endpoints
are unreachable directly. Every attack must pass through the WAF — which is exactly why
they register as blocked events in the portal.
## Host hardening (from original build)
- `/swapfile` mounted via `/etc/fstab` to mitigate OOM kills on the burstable e2-micro.
- `loginctl enable-linger matt` so rootless containers persist across reboots.
- `sysctl net.ipv4.ip_unprivileged_port_start=80` to let rootless Caddy bind low ports.
## Cert details (origin, Leg 2)
- Self-signed, RSA 2048, 3650-day validity.
- **SAN covers both** `DNS:flytoouter.space` and `IP:35.203.147.44`, so whichever way
Imperva addresses the backend, the cert matches.
- Files live on the host at `~/imperva-proxy/certs/{origin.crt,origin.key}` and are
bind-mounted read-only into Caddy at `/etc/caddy/certs/`.
+169
View File
@@ -0,0 +1,169 @@
# Design Decisions & Rationale
An ADR-style log of the choices made in this project, *why* they were made, and what was
rejected. Several of these were reversed mid-stream — the reversals are kept here on
purpose so nobody re-walks a dead end.
---
## D1 — WAF terminates client TLS; origin does not do public HTTPS
**Decision:** Imperva handles all public-facing TLS. The origin never runs ACME /
Let's Encrypt and never serves a publicly-trusted cert.
**Why:** The whole point of the lab is that Imperva is the edge. Having the origin also
try to obtain public certs is redundant, and on this locked-down host ACME would fail
anyway (egress is restricted and ingress is Imperva-only). Keeping cert management at
the edge is simpler and matches the intended topology.
---
## D2 — First attempt: plain-HTTP origin (REVERSED)
**Decision (later reversed):** Have Caddy serve only plain HTTP on `:80` and let
Imperva connect to the origin over HTTP; `auto_https off`.
**Why it was chosen:** The user's initial goal was "forward https/http traffic from the
WAF without Caddy doing SSL." The cleanest literal reading is: Imperva terminates TLS,
origin speaks plain HTTP, Caddy needs no cert at all.
**Why it was reversed:** The user then wanted a certificate they could upload to /
associate with Imperva (origin re-encryption). Plain HTTP has no cert to produce, so we
moved to a self-signed HTTPS origin (D5).
**Artifacts from this phase worth remembering:**
- `https://35.203.147.44/` returning `PR_CONNECT_RESET_ERROR` was *expected* here —
nothing was listening on 443. That reset is not a bug; it's the correct result of
hitting a port with no listener. Test HTTP origins with `curl -v http://localhost/`
from the host, and the real path via the hostname.
---
## D3 — Single catch-all site block in the Caddyfile
**Decision:** Exactly one site block (`:443`, or `:80` in the HTTP phase). No separate
hostname block.
**Why:** With `auto_https off`, a hostname block like `flytoouter.space { }` no longer
routes to 443 automatically — it binds the same plain port as an explicit `:80`/`:443`
block. Two blocks fighting over one port produces:
```
Error: adapting config using caddyfile: ambiguous site definition: :80
```
This actually happened (a leftover domain block from the pre-WAF config remained in the
file). The container crash-looped, spamming the error hundreds of times — that volume is
just the restart policy retrying, not hundreds of distinct problems.
**Rejected:** keeping host-specific blocks. If host-based routing is ever wanted (e.g.
rejecting requests whose Host isn't `flytoouter.space` as origin hardening), it goes
*inside* the single block as a `@host` matcher, never as a second site block.
---
## D4 — `handle` not `handle_path` for `/api/*`
**Decision:** Route the API with `handle /api/* { reverse_proxy … }`.
**Why:** `handle_path` strips the matched prefix; `handle` preserves it. The backend's
routes are defined under `/api/v1/...`, so the prefix must survive the proxy hop.
---
## D5 — Self-signed cert on the origin, generated with OpenSSL (not Caddy internal CA)
**Decision:** Generate the origin cert with `openssl` (self-signed, RSA 2048, 3650
days, SAN = DNS + IP) and hand the files to Caddy via `tls <crt> <key>`.
**Why OpenSSL over Caddy's `tls internal`:** `tls internal` mints a cert via Caddy's
internal CA and buries it in `/data/caddy/pki/...`, which is awkward to export. The lab
needs the cert *as a file on disk* — to upload to / associate with Imperva and to have
explicit control over the SAN. OpenSSL gives that in one command.
**Why self-signed is acceptable here:** Only Imperva connects to the origin. Browsers
only ever see Imperva's edge cert (D1). So the self-signed cert causes zero
visitor-facing warnings. The critical rule: **the self-signed cert belongs only on the
re-encryption leg, never on the edge/visitor leg.**
**SAN includes both DNS and IP** so Imperva matches regardless of whether it addresses
the origin by hostname or by `35.203.147.44`.
---
## D6 — Debugging the "cert not found" start failure
**Symptom:** `caddy validate` reported
`open /etc/caddy/certs/origin.crt: no such file or directory`, and curl to `:443` reset.
**Root cause & rule:** The config was fine; the cert file wasn't visible *inside the
container*. Two common causes on rootless Podman:
1. **Tilde didn't expand** in `-v ~/imperva-proxy/certs:...`, creating an empty
anonymous mount. Fix: use absolute paths (`/home/matt/...`).
2. Stale `:Z` SELinux relabel state from an earlier container. Fix: recreate the
container.
The TLS reset was a *symptom* of Caddy refusing to start the HTTPS server, not an
independent TLS problem. Always read the `validate` / `podman logs` output first.
---
## D7 — Three OWASP attacks for the demo: SQLi, Reflected XSS, Path Traversal
**Decision:** Demo exactly these three.
**Why these three:** Imperva blocks all three with **default, out-of-the-box
signatures** (no custom rules needed), and each surfaces as a **different, clearly
labeled event** in the portal (SQL Injection / Cross-Site Scripting / Illegal Resource
Access). Three distinct classifications reads as comprehensive coverage rather than
three flavors of one thing.
**Why credential stuffing / rate-limiting was left OUT of the demo set:** It needs a
**custom rate policy** configured in Imperva, not a signature match. It doesn't produce
the clean one-payload-one-block moment the other three do. Kept as an optional "bonus"
for later if we want to showcase custom rules.
---
## D8 — Deploy via `podman save | ssh podman load`, not a container registry
**Decision:** Build locally, pipe the image over the existing SSH connection into the
host's Podman, then swap the container via the `gcp-lab` connection alias.
**Why:** Zero new infrastructure. All the pieces already exist (SSH key, locked
ingress, remote Podman socket, connection alias). A registry (GCP Artifact Registry or
GHCR) would add credential management on both ends and, for Artifact Registry, IAM
wiring (service account + `artifactregistry.reader`) that's easy to get subtly wrong.
**Trade-off accepted:** `podman save` ships **every layer every time**, so each deploy
moves the full image even for a one-line change (~20–40s on this lab). The registry's
layer-caching win only pays off at high deploy frequency, which a demo lab isn't.
**Upgrade path:** If this ever becomes team-touched or high-frequency, migrating to
Artifact Registry is clean — only the *ship* step changes (`push`/`pull` instead of
`save | ssh load`); the rest of `deploy.sh` is unchanged.
---
## D9 — Build locally, never on the host
**Decision:** All image builds happen on the local workstation.
**Why:** The `e2-micro` (shared-core, burstable) doesn't have the memory to run the Vue
multi-stage build (npm install + build) reliably, even with the swapfile. Building
locally and shipping the finished image sidesteps OOM kills entirely.
---
## D10 — Frontend screens map 1:1 to attacks
**Decision:** Give the flight-booking SPA four screens, each a natural home for one
attack, so the demo feels like poking a real product rather than hitting raw endpoints:
- Passenger/admin lookup → SQLi (`/api/v1/users?q=`)
- Flight search results → Reflected XSS (`/api/v1/search?term=`)
- Boarding-pass download → Path Traversal (`/api/v1/download?file=`)
- Login → existing endpoint, kept as the credential-stuffing bonus
**Why:** A believable UI makes the "exploitable without the WAF, blocked with it"
narrative land better in a live demo than curling endpoints.
+61
View File
@@ -0,0 +1,61 @@
# Demo Runbook
> **Status: BUILT, not yet deployed to the GCP host.** All four screens and three
> attack endpoints exist in `backend/` and `frontend/` and were functionally verified
> locally (backend via `uvicorn` + `curl`, frontend via `vite dev` + a browser, talking
> to each other through the same `/api/*` path Caddy proxies in prod — including
> confirming the XSS payload actually executes and the traversal payload actually reads
> a file outside `boarding_passes/`). Not yet shipped with `./deploy.sh` — that must run
> from a real host terminal, not an AI coding-agent sandbox (see CLAUDE.md).
Goal of the demo: type an attack into a believable flight-booking UI, watch Imperva
block it, then show the corresponding event in the Imperva portal. Three attacks, three
*distinct* portal classifications.
## Attack matrix
| # | Attack | Endpoint | Sample payload | Expected Imperva portal classification |
|---|-----------------|-----------------------------|------------------------------------|------------------------------------------------|
| 1 | SQL Injection | `/api/v1/users?q=` | `' OR '1'='1` | SQL Injection |
| 2 | Reflected XSS | `/api/v1/search?term=` | `<script>alert(1)</script>` | Cross-Site Scripting (XSS) |
| 3 | Path Traversal | `/api/v1/download?file=` | `../../../../etc/passwd` | Illegal Resource Access / Directory Traversal |
| — | (bonus) Cred stuffing | `/api/v1/login` | repeated login attempts | Requires a **custom rate policy**, not a signature — not in core demo |
Attacks 1–3 are blocked by Imperva's **default signatures** — no custom rules needed.
The bonus is intentionally excluded from the core demo because it needs a rate policy
configured in Imperva and doesn't produce a clean single-payload block (see
DECISIONS.md D7).
## Frontend screen → attack mapping
Each attack has a natural home in the UI so the demo looks like poking a real product:
- **Passenger / admin lookup** → SQLi search box (`users?q=`)
- **Flight search results** → XSS search field (`search?term=`)
- **Boarding-pass download** → path-traversal file param (`download?file=`)
- **Login** → existing endpoint, kept as the credential-stuffing bonus
## Demo flow (target)
1. Show the app working normally (a legitimate search, a legitimate lookup).
2. For each of the three attacks: enter the payload in its screen → show the request is
blocked by Imperva (block page / rejected response).
3. Switch to the Imperva portal security dashboard → show the three distinct event
types logged, one per attack.
4. Reinforce the isolation point: the origin only accepts Imperva CIDRs, so these
attacks *cannot* reach the app except through the WAF — which is why every one of
them appears in the portal.
## Pre-demo checklist
- `https://flytoouter.space/` loads with Imperva's trusted edge cert.
- All three endpoints reachable *through* Imperva (legit requests return 200).
- Origin firewall confirmed Imperva-CIDR-only (`allow-imperva-waf`, `tcp:443`).
- DNS for `flytoouter.space` points at Imperva, not the raw origin IP.
- Portal logged-in and on the security events view, filtered to the demo site.
## Notes on portal specifics
Exact event labels and menu paths vary by Imperva account/version. Confirm the live
labels in your portal before presenting and update the classification column above to
match what your account actually shows.
+95
View File
@@ -0,0 +1,95 @@
# Deploy Workflow
Local code → finished image shipped over SSH → container swapped on the host. No
container registry (see DECISIONS.md D8 for why).
## The two channels to the host
Both go to the same GCP box; they're used for different steps:
- **`podman save … | ssh … podman load`** — ships the image *bytes*. This has to use
raw `ssh` because it's a pipe into the remote Podman CLI.
- **`podman --connection gcp-lab …`** — swaps the running container over the existing
rootless socket, hands-free (no manual SSH). The alias is already configured and set
as default:
`ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock`.
> The socket is under **UID 1001** on this host (the original architecture doc's 1000 is
> wrong). Using the alias means you never have to think about the UID.
## Why build locally
The `e2-micro` can't build the Vue multi-stage image reliably (memory). Always build on
the local workstation and ship the finished image. Never build on the host.
## `deploy.sh`
Lives at the repo root and is run from there (so the relative `./backend` and
`./frontend` build contexts resolve). Config block at the top holds host/user/key/alias.
Usage:
```bash
./deploy.sh # full cycle: build both, ship both, swap both containers
VERSION=v2 ./deploy.sh # tag images v2 instead of a timestamp → named rollback point
./deploy.sh --build --ship --swap # explicit equivalent of the default
./deploy.sh --restart-caddy # after editing the Caddyfile to add/modify routes
```
With no arguments it runs `build → ship → swap`. `VERSION` defaults to a timestamp; set
it explicitly to create a named point you can redeploy later.
## Steps the script performs
1. **build** — `podman build` the backend and frontend images locally.
2. **ship** — `podman save <img> | ssh -i <key> matt@35.203.147.44 'podman load'` for
each image.
3. **swap** — over `--connection gcp-lab`: `rm -f` then `run -d` each container with its
name and port mapping.
4. **restart-caddy** (on demand) — `podman --connection gcp-lab restart caddy-proxy`,
needed only when the Caddyfile changed (e.g. new XSS/traversal routes).
## IMPORTANT: keep `swap()` in sync with real run flags
The current `swap()` recreates containers with only `--name` and `-p`. It does **not**
yet reproduce any bind mounts (e.g. the SQLite `demo.db`), config-file mounts, or
network/DNS-alias flags the real containers depend on. **Before relying on the script,
confirm the actual `podman run` flags** the current `backend-api` and `frontend-vue`
were created with and fold them into `swap()`, or a deploy will silently drop them.
To inspect what the running containers actually use:
```bash
podman --connection gcp-lab inspect backend-api | jq '.[0].Mounts, .[0].NetworkSettings'
podman --connection gcp-lab inspect frontend-vue | jq '.[0].Mounts, .[0].NetworkSettings'
```
## Caddy config / cert deploys
The Caddyfile and certs live on the host at `~/imperva-proxy/` and are bind-mounted
read-only into `caddy-proxy`. To change routing:
1. Edit `imperva-proxy/Caddyfile` in the repo, copy it to the host `~/imperva-proxy/`.
2. Validate before restarting so you're not debugging from a crash loop:
```bash
podman --connection gcp-lab exec caddy-proxy caddy validate --config /etc/caddy/Caddyfile
```
3. `./deploy.sh --restart-caddy`.
If the container is already crash-looping and `exec` won't attach, validate against the
mount with a throwaway container instead:
```bash
podman run --rm -v /home/matt/imperva-proxy/Caddyfile:/etc/caddy/Caddyfile:ro,Z \
docker.io/library/caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
```
## Verifying a deploy
- Origin health from the host (self-signed, so `-k`):
```bash
podman --connection gcp-lab exec caddy-proxy sh -c 'wget -qO- --no-check-certificate https://localhost/ | head'
```
(or `curl -vk https://localhost/` directly on the host)
- Real path end to end: load `https://flytoouter.space/` in a browser — should show
Imperva's trusted edge cert while the origin serves self-signed to Imperva only.