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:
commit
4d60fdd139
28 files changed
+2609
No files matched your search
@@ -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/`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user