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

+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.