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,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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user