Files
matt 4d60fdd139 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.
2026-07-29 11:08:56 -07:00

7.5 KiB
Raw Permalink Blame History

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.