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

+7
View File
@@ -0,0 +1,7 @@
imperva-proxy/certs/*.key
node_modules/
dist/
__pycache__/
*.pyc
*.db
.venv/
+158
View File
@@ -0,0 +1,158 @@
# CLAUDE.md
Context and working guide for the Imperva WAF demo lab. Read this first, then the
files under `docs/` for depth. This file is meant to orient any AI agent (or human)
picking up the project so they don't re-derive decisions that were already made and
deliberately reversed once.
---
## What this project is
A deliberately-vulnerable flight-booking web app sitting behind the **Imperva Cloud
WAF**, used to demonstrate that the WAF detects and blocks common OWASP attacks and
surfaces them in the Imperva portal. The app itself is not the point — it exists to
give the WAF something realistic to protect and to produce clean, distinct blocked
events on screen during a live demo.
The stack runs as **rootless Podman containers** on a single small GCP instance. All
public traffic is forced through Imperva; the origin only accepts connections from
Imperva's IP ranges.
## Current state (as of this handoff)
- Backend (`backend-api`), frontend (`frontend-vue`), and Caddy (`caddy-proxy`) are
deployed and working on the GCP host.
- **Edge/origin TLS is done and verified.** Imperva terminates the visitor's TLS at
the edge and **re-encrypts to the origin over HTTPS** using a **self-signed cert**
that Caddy serves on `:443`. Confirmed working end to end (`https://flytoouter.space/`
loads with Imperva's trusted edge cert; origin serves self-signed to Imperva only).
- A **local-build → ship-over-SSH → swap-on-host** deploy pipeline is defined
(`deploy.sh`). See `docs/DEPLOY.md`.
- **Repo scaffolded locally** by pulling the exact source that's live on the host
(`~/imperva-backend`, `~/imperva-frontend`, `~/imperva-proxy`) via `scp`, so the repo
matches what's actually running, not just what was intended.
- **The frontend on the GCP host is still a near-empty stub** (just whitespace and a
page title) — the real four-screen Vue SPA now exists in this repo's `frontend/` but
has not been shipped yet. See "What's next".
## What's next
The four-screen frontend and three attack endpoints described below are **built and
locally verified**, but **not yet deployed to the GCP host** — the host still runs the
old stub frontend and two-endpoint backend. Next step is running `./deploy.sh` from a
real host terminal (see the Podman/sandbox gotcha below), then walking the
`docs/DEMO_RUNBOOK.md` pre-demo checklist against `https://flytoouter.space/`.
Three OWASP attacks, each blocked by an Imperva **default signature** and shown as a
*distinct* event type in the portal:
1. SQL Injection — `/api/v1/users?q=` (Passenger / Admin Lookup screen)
2. Reflected XSS — `/api/v1/search?term=` (Flight Search screen)
3. Path Traversal — `/api/v1/download?file=` (Boarding Pass Download screen)
Plus a Login screen (`/api/v1/login`) kept as the credential-stuffing bonus (not in the
core demo — needs a custom rate policy, see DECISIONS.md D7).
See `docs/DEMO_RUNBOOK.md` for payloads and expected portal classifications.
**Resolved:** confirmed via `podman inspect` and the host's `.bash_history` that
`backend-api` and `frontend-vue` have **no bind mounts** — `demo.db` is created inside
the image by `init_db()` on container start, not mounted from the host. The real run
commands were:
```
podman run -d --name backend-api --restart always -p 8000:8000 backend-api
podman run -d --name frontend-vue --restart always -p 8080:8080 frontend-vue
```
`deploy.sh`'s `swap()` reproduces exactly this — `--name` and `-p` only, nothing else
needed.
---
## Key facts an agent needs (these override the original handoff doc)
- **Host:** GCP `e2-micro`, Ubuntu, static IP `35.203.147.44`, domain `flytoouter.space`.
- **Host user:** `matt`. **Rootless Podman socket is under UID `1001`, not 1000** —
the original architecture doc says 1000; that was wrong for this host. Prefer the
connection alias so the UID is irrelevant.
- **Podman connection alias (local → host):** `gcp-lab`, already configured and set as
default:
`ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock`
key: `~/.ssh/google_compute_engine`.
- **SSH ingress** is locked to the operator's static residential IP.
- **HTTP/HTTPS ingress** (`allow-imperva-waf`) is restricted to Imperva's published
IPv4 CIDRs only; all `0.0.0.0/0` rules were purged. The vulnerable app is therefore
never directly reachable — attacks must traverse the WAF, which is what makes them
appear in the portal.
- **e2-micro cannot build the Vue multi-stage image** (npm build is memory-hungry even
with the swapfile). Always build locally and ship the finished image. Never build on
the host.
## Repo layout (intended)
```
.
├── CLAUDE.md # this file
├── deploy.sh # local build → ship over ssh → swap on host
├── backend/ # FastAPI app + Containerfile
├── frontend/ # Vue SPA + multi-stage Containerfile
├── imperva-proxy/
│ ├── Caddyfile # origin reverse proxy (mirrors host ~/imperva-proxy/)
│ └── certs/ # origin.crt / origin.key (self-signed; do NOT commit key)
└── docs/
├── ARCHITECTURE.md # topology, TLS legs, request flow, network
├── DECISIONS.md # every design choice + why + rejected alternatives
├── DEPLOY.md # the ship-over-ssh workflow in detail
└── DEMO_RUNBOOK.md # attacks, payloads, expected portal events
```
> Note: On the **host**, the Caddyfile and certs live at `~/imperva-proxy/`. Keep the
> repo copy in sync with what's mounted into `caddy-proxy`.
## Conventions / gotchas (learned the hard way — see DECISIONS.md)
- **One catch-all `:80`/`:443` site block in the Caddyfile.** With `auto_https off`, a
hostname block (e.g. `flytoouter.space { }`) collapses onto the same port as a
`:443`/`:80` block and Caddy fails with `ambiguous site definition`. Host-based
routing, if ever needed, goes *inside* the single block as a matcher — never as a
second site block.
- **Use `handle`, not `handle_path`,** for `/api/*` so the `/api` prefix is preserved
for the backend.
- **Mount paths in `podman run` must be absolute** (`/home/matt/...`), not `~`. A
tilde that doesn't expand creates an empty anonymous mount and Caddy dies with
`open /etc/caddy/certs/origin.crt: no such file or directory`.
- **Origin cert is self-signed on purpose.** Only Imperva sees it, so no browser
warnings. Never put the self-signed cert on the *edge* (visitor-facing) side — that
stays Imperva's managed cert.
- **`https://35.203.147.44/` resetting is expected** when the origin serves only the
intended port and/or your source IP isn't an Imperva CIDR. Test the origin from the
host with `curl -vk https://localhost/`, and test the real path via
`https://flytoouter.space/`.
- **Do not commit `origin.key`.** Add `imperva-proxy/certs/*.key` to `.gitignore`.
- **`podman` builds must run on the real host terminal, not inside a sandboxed
coding-agent shell.** If you're an AI agent editing this repo from within a sandbox,
check whether `podman`/`docker` resolves before assuming you can run `deploy.sh`
yourself. On this project's dev machine, the agent sandbox is a *nested* container
(different userland/libc than the real host) sitting on the same physical disk as
`~/workspace` — so files edited in the sandbox are real and immediately usable, but a
`podman` binary installed *inside* the sandbox will collide with the host's existing
rootless container storage (`~/.local/share/containers`, shared because it's the same
disk) and fail with a `database configuration mismatch` error. Don't try to work
around this by resetting or pointing storage elsewhere — it risks corrupting the
host's real container state. Edit code from the sandbox; run `./deploy.sh` from a
normal terminal on the actual workstation.
## How to deploy (short version)
From the repo root, with the `gcp-lab` connection present:
```bash
./deploy.sh # build both images, ship over ssh, swap both containers
VERSION=v2 ./deploy.sh # same, but tag images v2 for a named rollback point
./deploy.sh --restart-caddy # after editing the Caddyfile (new routes)
```
Full detail, including per-step flags and the layer-shipping trade-off, in
`docs/DEPLOY.md`.
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY boarding_passes/ ./boarding_passes/
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+11
View File
@@ -0,0 +1,11 @@
====================================
OUTER SPACE FLIGHTS - BOARDING PASS
====================================
Passenger: J. DOE
Flight: OUT101
Route: Earth (JFK) -> Lunar Gateway
Departs: 2026-08-14 09:00
Seat: 14C
Gate: G7
Status: CONFIRMED
====================================
+11
View File
@@ -0,0 +1,11 @@
====================================
OUTER SPACE FLIGHTS - BOARDING PASS
====================================
Passenger: A. ALICE
Flight: OUT202
Route: Earth (LAX) -> Mars Colony Alpha
Departs: 2026-09-02 03:30
Seat: 02A
Gate: G14
Status: CONFIRMED
====================================
+131
View File
@@ -0,0 +1,131 @@
import os
import sqlite3
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
app = FastAPI(title="Imperva Demo Leaky API", version="1.0.0")
DB_FILE = "demo.db"
BOARDING_PASS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "boarding_passes")
FLIGHTS = [
{"id": "OUT101", "origin": "Earth (JFK)", "destination": "Lunar Gateway", "depart": "2026-08-14 09:00", "price": 128000},
{"id": "OUT202", "origin": "Earth (LAX)", "destination": "Mars Colony Alpha", "depart": "2026-09-02 03:30", "price": 890000},
{"id": "OUT303", "origin": "Earth (LHR)", "destination": "Europa Research Station", "depart": "2026-10-21 14:15", "price": 2450000},
{"id": "OUT404", "origin": "Lunar Gateway", "destination": "Mars Colony Alpha", "depart": "2026-08-30 22:00", "price": 610000},
{"id": "OUT505", "origin": "Earth (NRT)", "destination": "Titan Outpost", "depart": "2026-11-11 06:45", "price": 3980000},
]
def init_db():
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
email TEXT,
role TEXT
)
""")
cursor.execute("SELECT COUNT(*) FROM users")
if cursor.fetchone()[0] == 0:
cursor.executemany("""
INSERT INTO users (username, email, role) VALUES (?, ?, ?)
""", [
("admin", "admin@flytoouter.space", "administrator"),
("jdoe", "jdoe@flytoouter.space", "engineer"),
("alice", "alice@flytoouter.space", "analyst")
])
conn.commit()
conn.close()
init_db()
class LoginRequest(BaseModel):
username: str
password: str
@app.get("/api/v1/health")
def health_check():
"""Endpoint for uptime and monitoring checks."""
return {"status": "ok", "service": "imperva-demo-backend"}
@app.post("/api/v1/login")
def login(credentials: LoginRequest):
"""
Intentionally lacks rate limiting or lockout protection.
Demo Use: Showcases Imperva Advanced Bot Protection / Credential Stuffing mitigation.
"""
if credentials.username == "admin" and credentials.password == "Secret123!":
return {"status": "authenticated", "token": "demo-jwt-token-xyz789"}
return {"status": "failed", "message": "Invalid credentials"}
@app.get("/api/v1/users")
def search_users(q: str = Query("", description="Search username")):
"""
INTENTIONAL VULNERABILITY: Raw string concatenation for SQL query.
Demo Use: Triggers Imperva default SQL Injection (SQLi) detection rules when passing payloads like `' OR '1'='1`.
"""
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
query = f"SELECT id, username, email, role FROM users WHERE username LIKE '%{q}%'"
try:
cursor.execute(query)
rows = cursor.fetchall()
conn.close()
return [{"id": r[0], "username": r[1], "email": r[2], "role": r[3]} for r in rows]
except Exception as e:
conn.close()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
@app.get("/api/v1/search")
def search_flights(term: str = Query("", description="Destination search term")):
"""
INTENTIONAL VULNERABILITY: the search term is echoed back verbatim in
`query_echo` and the frontend renders it unescaped (v-html).
Demo Use: Triggers Imperva default Cross-Site Scripting (XSS) detection rules
when passing payloads like `<script>alert(1)</script>`.
"""
term_lower = term.lower()
results = [
f for f in FLIGHTS
if term_lower in f["origin"].lower() or term_lower in f["destination"].lower()
] if term else FLIGHTS
return {
"query_echo": f"Showing results for: {term}" if term else "Showing all flights",
"results": results,
}
@app.get("/api/v1/download")
def download_boarding_pass(file: str = Query(..., description="Boarding pass filename")):
"""
INTENTIONAL VULNERABILITY: raw path concatenation, no traversal sanitization.
Demo Use: Triggers Imperva default Path Traversal / Illegal Resource Access
detection rules when passing payloads like `../../../../etc/passwd`.
"""
path = os.path.join(BOARDING_PASS_DIR, file)
try:
with open(path, "r") as f:
content = f.read()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Boarding pass not found")
except IsADirectoryError:
raise HTTPException(status_code=400, detail="Not a file")
return PlainTextResponse(content)
@app.get("/api/v1/hidden-config")
def hidden_config():
"""
Unauthenticated endpoint exposing internal application state.
Demo Use: API Discovery & custom URI blocking rules.
"""
return {
"env": "production-demo",
"db_connection": "sqlite:///demo.db",
"internal_api_key": "sk_live_demo_998877665544332211",
"debug_mode": True
}
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.111.0
uvicorn==0.30.1
pydantic==2.7.4
Executable
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Local build -> ship over ssh -> swap on host. No container registry (see docs/DECISIONS.md D8).
#
# Run this from a real terminal on your workstation (NOT inside a sandboxed
# coding-agent shell) with the `gcp-lab` podman connection already configured:
#
# podman system connection add gcp-lab \
# --identity ~/.ssh/google_compute_engine \
# ssh://matt@35.203.147.44:22/run/user/1001/podman/podman.sock
#
# Usage:
# ./deploy.sh # build both, ship both, swap both containers
# VERSION=v2 ./deploy.sh # tag images v2 instead of a timestamp
# ./deploy.sh --build --ship --swap # explicit equivalent of the default
# ./deploy.sh --restart-caddy # after editing imperva-proxy/Caddyfile
set -euo pipefail
# --- config -------------------------------------------------------------
HOST="35.203.147.44"
HOST_USER="matt"
SSH_KEY="$HOME/.ssh/google_compute_engine"
CONNECTION="gcp-lab"
VERSION="${VERSION:-$(date +%Y%m%d%H%M%S)}"
REMOTE_PROXY_DIR="/home/matt/imperva-proxy"
BACKEND_IMAGE="backend-api"
FRONTEND_IMAGE="frontend-vue"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# --- steps ----------------------------------------------------------------
do_build() {
echo "==> Building ${BACKEND_IMAGE}:${VERSION}"
podman build -t "${BACKEND_IMAGE}:${VERSION}" -t "${BACKEND_IMAGE}:latest" ./backend
echo "==> Building ${FRONTEND_IMAGE}:${VERSION}"
podman build -t "${FRONTEND_IMAGE}:${VERSION}" -t "${FRONTEND_IMAGE}:latest" ./frontend
}
do_ship() {
echo "==> Shipping ${BACKEND_IMAGE}:${VERSION} over ssh"
podman save "${BACKEND_IMAGE}:${VERSION}" | ssh -i "$SSH_KEY" "${HOST_USER}@${HOST}" podman load
echo "==> Shipping ${FRONTEND_IMAGE}:${VERSION} over ssh"
podman save "${FRONTEND_IMAGE}:${VERSION}" | ssh -i "$SSH_KEY" "${HOST_USER}@${HOST}" podman load
}
do_swap() {
echo "==> Swapping backend-api container"
podman --connection "$CONNECTION" rm -f backend-api >/dev/null 2>&1 || true
podman --connection "$CONNECTION" run -d \
--name backend-api \
--restart always \
-p 8000:8000 \
"${BACKEND_IMAGE}:${VERSION}"
echo "==> Swapping frontend-vue container"
podman --connection "$CONNECTION" rm -f frontend-vue >/dev/null 2>&1 || true
podman --connection "$CONNECTION" run -d \
--name frontend-vue \
--restart always \
-p 8080:8080 \
"${FRONTEND_IMAGE}:${VERSION}"
}
do_restart_caddy() {
echo "==> Copying Caddyfile to host"
scp -i "$SSH_KEY" ./imperva-proxy/Caddyfile "${HOST_USER}@${HOST}:${REMOTE_PROXY_DIR}/Caddyfile"
echo "==> Validating Caddyfile on host"
podman --connection "$CONNECTION" exec caddy-proxy caddy validate --config /etc/caddy/Caddyfile
echo "==> Restarting caddy-proxy"
podman --connection "$CONNECTION" restart caddy-proxy
}
check_connection() {
if ! podman system connection list --format '{{.Name}}' 2>/dev/null | grep -qx "$CONNECTION"; then
echo "error: podman connection '${CONNECTION}' is not configured." >&2
echo " podman system connection add ${CONNECTION} --identity ${SSH_KEY} ssh://${HOST_USER}@${HOST}:22/run/user/1001/podman/podman.sock" >&2
exit 1
fi
}
# --- arg parsing ------------------------------------------------------
RUN_BUILD=false
RUN_SHIP=false
RUN_SWAP=false
RUN_RESTART_CADDY=false
if [ "$#" -eq 0 ]; then
RUN_BUILD=true
RUN_SHIP=true
RUN_SWAP=true
else
for arg in "$@"; do
case "$arg" in
--build) RUN_BUILD=true ;;
--ship) RUN_SHIP=true ;;
--swap) RUN_SWAP=true ;;
--restart-caddy) RUN_RESTART_CADDY=true ;;
*)
echo "unknown flag: $arg" >&2
exit 1
;;
esac
done
fi
if [ "$RUN_SHIP" = true ] || [ "$RUN_SWAP" = true ] || [ "$RUN_RESTART_CADDY" = true ]; then
check_connection
fi
[ "$RUN_BUILD" = true ] && do_build
[ "$RUN_SHIP" = true ] && do_ship
[ "$RUN_SWAP" = true ] && do_swap
[ "$RUN_RESTART_CADDY" = true ] && do_restart_caddy
echo "==> Done (version: ${VERSION})"
+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.
+13
View File
@@ -0,0 +1,13 @@
# Stage 1: Build the Vue App
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve static files with Nginx
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Outer Space Flights</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
server {
listen 8080;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
+1242
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
{
"name": "imperva-demo-frontend",
"scripts": { "dev": "vite", "build": "vite build" },
"dependencies": { "vue": "^3.4.0" },
"devDependencies": { "vite": "^5.2.0", "@vitejs/plugin-vue": "^5.0.0" }
}
+42
View File
@@ -0,0 +1,42 @@
<script setup>
import { ref, shallowRef } from 'vue'
import FlightSearch from './views/FlightSearch.vue'
import PassengerLookup from './views/PassengerLookup.vue'
import BoardingPassDownload from './views/BoardingPassDownload.vue'
import LoginView from './views/LoginView.vue'
const tabs = [
{ key: 'flights', label: 'Flight Search', component: FlightSearch },
{ key: 'passengers', label: 'Passenger Lookup', component: PassengerLookup },
{ key: 'boarding', label: 'Boarding Pass', component: BoardingPassDownload },
{ key: 'login', label: 'Login', component: LoginView },
]
const active = ref('flights')
const activeComponent = shallowRef(tabs[0].component)
function select(tab) {
active.value = tab.key
activeComponent.value = tab.component
}
</script>
<template>
<div class="topbar">
<div class="brand">Outer Space Flights</div>
<nav class="tabs">
<button
v-for="tab in tabs"
:key="tab.key"
:class="{ active: active === tab.key }"
@click="select(tab)"
>
{{ tab.label }}
</button>
</nav>
</div>
<main>
<component :is="activeComponent" />
</main>
</template>
+31
View File
@@ -0,0 +1,31 @@
const BASE = '/api/v1'
async function request(path) {
const res = await fetch(`${BASE}${path}`)
const body = await res.json().catch(() => null)
return { ok: res.ok, status: res.status, body }
}
export function searchFlights(term) {
return request(`/search?term=${encodeURIComponent(term)}`)
}
export function searchUsers(q) {
return request(`/users?q=${encodeURIComponent(q)}`)
}
export async function downloadBoardingPass(file) {
const res = await fetch(`${BASE}/download?file=${encodeURIComponent(file)}`)
const text = await res.text()
return { ok: res.ok, status: res.status, text }
}
export async function login(username, password) {
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const body = await res.json().catch(() => null)
return { ok: res.ok, status: res.status, body }
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+148
View File
@@ -0,0 +1,148 @@
:root {
--bg: #0b1020;
--panel: #131a2e;
--border: #232c47;
--text: #e6e9f2;
--muted: #8a92b2;
--accent: #5b8cff;
--accent-2: #7c5bff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: linear-gradient(160deg, var(--bg), #050810);
color: var(--text);
min-height: 100vh;
}
#app { min-height: 100vh; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.brand {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
nav.tabs {
display: flex;
gap: 0.5rem;
}
nav.tabs button {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 0.5rem 1rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.9rem;
}
nav.tabs button.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
main {
max-width: 860px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
}
.panel h2 {
margin-top: 0;
}
.field-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"],
input[type="password"] {
flex: 1;
background: #0d1326;
border: 1px solid var(--border);
color: var(--text);
padding: 0.6rem 0.8rem;
border-radius: 8px;
font-size: 0.95rem;
}
button.primary {
background: var(--accent);
border: none;
color: white;
padding: 0.6rem 1.2rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.95rem;
}
button.primary:hover { opacity: 0.9; }
.echo {
color: var(--muted);
margin-bottom: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
th { color: var(--muted); font-weight: 600; }
pre.result {
background: #0d1326;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
white-space: pre-wrap;
word-break: break-word;
font-size: 0.85rem;
}
.status {
margin-top: 0.75rem;
font-size: 0.9rem;
}
.status.error { color: #ff6b6b; }
.status.ok { color: #63e6be; }
.hint {
color: var(--muted);
font-size: 0.85rem;
margin-top: 1.5rem;
}
@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue'
import { downloadBoardingPass } from '../api.js'
const file = ref('BP-1001.txt')
const content = ref('')
const error = ref('')
async function runDownload() {
error.value = ''
content.value = ''
const { ok, status, text } = await downloadBoardingPass(file.value)
if (!ok) {
error.value = `Request failed (${status})`
return
}
content.value = text
}
</script>
<template>
<section class="panel">
<h2>Boarding Pass Download</h2>
<div class="field-row">
<input
type="text"
v-model="file"
placeholder="Boarding pass filename (e.g. BP-1001.txt)"
@keyup.enter="runDownload"
/>
<button class="primary" @click="runDownload">Download</button>
</div>
<p class="status error" v-if="error">{{ error }}</p>
<pre class="result" v-if="content">{{ content }}</pre>
</section>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup>
import { ref } from 'vue'
import { searchFlights } from '../api.js'
const term = ref('')
const queryEcho = ref('')
const results = ref([])
const searched = ref(false)
async function runSearch() {
const { body } = await searchFlights(term.value)
queryEcho.value = body?.query_echo ?? ''
results.value = body?.results ?? []
searched.value = true
}
</script>
<template>
<section class="panel">
<h2>Flight Search</h2>
<div class="field-row">
<input
type="text"
v-model="term"
placeholder="Search destinations (e.g. Mars)"
@keyup.enter="runSearch"
/>
<button class="primary" @click="runSearch">Search</button>
</div>
<!-- Intentionally unescaped: the API echoes the raw search term back
verbatim and this renders it as HTML, not text. -->
<p class="echo" v-if="searched" v-html="queryEcho"></p>
<table v-if="results.length">
<thead>
<tr>
<th>Flight</th>
<th>Origin</th>
<th>Destination</th>
<th>Departs</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr v-for="f in results" :key="f.id">
<td>{{ f.id }}</td>
<td>{{ f.origin }}</td>
<td>{{ f.destination }}</td>
<td>{{ f.depart }}</td>
<td>${{ f.price.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</section>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup>
import { ref } from 'vue'
import { login } from '../api.js'
const username = ref('')
const password = ref('')
const message = ref('')
const success = ref(false)
async function runLogin() {
const { body } = await login(username.value, password.value)
success.value = body?.status === 'authenticated'
message.value = success.value
? `Authenticated. Token: ${body.token}`
: (body?.message ?? 'Login failed')
}
</script>
<template>
<section class="panel">
<h2>Login</h2>
<div class="field-row">
<input type="text" v-model="username" placeholder="Username" />
</div>
<div class="field-row">
<input type="password" v-model="password" placeholder="Password" @keyup.enter="runLogin" />
<button class="primary" @click="runLogin">Log in</button>
</div>
<p class="status" :class="success ? 'ok' : 'error'" v-if="message">{{ message }}</p>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue'
import { searchUsers } from '../api.js'
const q = ref('')
const results = ref([])
const error = ref('')
async function runSearch() {
error.value = ''
const { ok, body } = await searchUsers(q.value)
if (!ok) {
error.value = body?.detail ?? 'Search failed'
results.value = []
return
}
results.value = body ?? []
}
</script>
<template>
<section class="panel">
<h2>Passenger / Admin Lookup</h2>
<div class="field-row">
<input
type="text"
v-model="q"
placeholder="Search by username"
@keyup.enter="runSearch"
/>
<button class="primary" @click="runSearch">Search</button>
</div>
<p class="status error" v-if="error">{{ error }}</p>
<table v-if="results.length">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr v-for="u in results" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.email }}</td>
<td>{{ u.role }}</td>
</tr>
</tbody>
</table>
</section>
</template>
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
// Mirrors the Caddy `/api/*` -> backend route used in prod, so the
// frontend talks to the API the same way (same-origin) in dev and prod.
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})
+15
View File
@@ -0,0 +1,15 @@
{
auto_https off
}
:443 {
tls /etc/caddy/certs/origin.crt /etc/caddy/certs/origin.key
handle /api/* {
reverse_proxy host.containers.internal:8000
}
handle {
reverse_proxy host.containers.internal:8080
}
}
+20
View File
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDOjCCAiKgAwIBAgIUXT8uwyaJMEENfQOgs6SsXZ4W+C0wDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQZmx5dG9vdXRlci5zcGFjZTAeFw0yNjA3MjgyMDAyNTRa
Fw0zNjA3MjUyMDAyNTRaMBsxGTAXBgNVBAMMEGZseXRvb3V0ZXIuc3BhY2UwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3gl823XO7+Qx09H0rHoocm/YE
NeUYXFtWLevhptFYOpoyU+EafsogrHMJO9dy0Tnt/sA6dv+Td7oF4BxrJykbr6Fh
+xGonXGck/vTh7RE4eNClmQBZ7jJrMN0BhCtw+gvmUVgzNAz2ItqoCs37fX2EC6+
AcApAbWIAnNmNoKa7Sc6CL+uGm6I3eW769J72rDxhm27ZUtFZZTxF8jhc64kR6TM
TDSAMLt2axlUHsYZ4GdLjsJ47rb461Cn9smUX4SFD74qSNmYDPzZbBgvmi+/UkC7
lFsT+6FwYP/wc3eRVjVhb6j1mjG/UiVwYY59eUoKbN1Ebl/XpvJHs4skxqwLAgMB
AAGjdjB0MB0GA1UdDgQWBBTEJ04zRuHst5AwwW32lpBe43YONzAfBgNVHSMEGDAW
gBTEJ04zRuHst5AwwW32lpBe43YONzAPBgNVHRMBAf8EBTADAQH/MCEGA1UdEQQa
MBiCEGZseXRvb3V0ZXIuc3BhY2WHBCPLkywwDQYJKoZIhvcNAQELBQADggEBAGAH
EkzsBrSa30386HeOLChmbgIeCIDQLtfwPSgMtTunWTHG5fsDWIMoOOYEWT/dZdwj
8pw7FbvkKYDbnU8QufP3v+qyfvgsvAlQ9Is5+E9eTm0mmBi/zQBBZCe+vzF1EfMp
xHcvsuVwoDgaQ39Tk6rzERjpr2oIhf6UL4IFc7xP8/vmyUaIzFffxuFXMuheqGnv
uDqV+HxsJbaQnbqfRGxHmEoXNz4YhuUvT1qFgh0aeB/PDgr1T7gGvP/nrfJ0gA6D
Tvk7xnjWCeo+aKvHO6ZNqCrLqJbi5MgitdEQ5GYX4PGRH4xay9fpGlwP47oqS5Gn
XxFeG/YP/3uKDgjBOSE=
-----END CERTIFICATE-----