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

+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,
},
},
},
})