Initial commit: TravelHub multi-tenant flight booking platform

Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
Binary file not shown.
+396
View File
@@ -0,0 +1,396 @@
# TravelHub — Whitebox Vulnerability Assessment & Penetration Test
**Engagement type:** Authorised whitebox (source-assisted) VAPT
**Target:** TravelHub multi-tenant flight-booking platform — Go API (`:8080`), Angular SPA (`:4200`), PostgreSQL 17
**Scope:** `backend/`, `frontend/`, database, and running instances on localhost
**Method:** Source review of the full codebase plus dynamic testing against the running stack
**Date:** 12 September 2026
---
## 1. Executive summary
TravelHub is architecturally sound on the issues that most often sink a
multi-tenant application. Tenant isolation holds under direct testing, SQL is
parameterised throughout, the JSON decoder rejects unknown fields, request
bodies are size-capped, and the Angular client escapes interpolated data by
default. The 311-test automated suite already guards the core business-logic
invariants (seat accounting, cross-tenant reads, overselling).
The weaknesses found are **operational and defence-in-depth**, not broken access
control. The two that matter most are a **database superuser connection** — which
turns any application compromise into total database control and nullifies the
carefully built application-layer tenant isolation — and a **timing side-channel
that backfired**: the mitigation intended to hide which usernames exist actually
makes them *more* distinguishable.
None of the findings is a remotely exploitable pre-authentication breach on the
current localhost deployment. Several become serious the moment this is exposed
beyond localhost, which the README states is the intended next step.
### Findings at a glance
| # | Severity | Finding |
| - | -------- | ------- |
| 1 | **High** | Application connects to PostgreSQL as a superuser (no least privilege) |
| 2 | **High** | Username enumeration via a timing oracle — the equalisation mitigation is miscalibrated and inverts |
| 3 | Medium | Bcrypt cost factor 6, below the OWASP-recommended minimum of 10 |
| 4 | Medium | No rate limiting or account lockout on authentication |
| 5 | Medium | Tenant enumeration via timing (unknown tenant short-circuits before password check) |
| 6 | Medium | No HTTP security headers (HSTS, X-Content-Type-Options, X-Frame-Options, CSP…) |
| 7 | Medium | Database connections run in plaintext (`sslmode=disable`) |
| 8 | Low | Real database password committed in `config_test.go` |
| 9 | Low | Credentials held reversibly in `localStorage` and replayed on every request (inherent to Basic auth) |
| 10 | Low | API stores markup verbatim — no server-side output encoding (defence-in-depth only) |
| 11 | Info | `RequireAdmin` middleware is implemented but wired to no route; the role model is unenforced |
---
## 2. Scope, method and rules of engagement
This was a **whitebox** test: the assessor had the full source tree, the
database, and running instances. Testing combined:
- **Static review** — grep-driven and manual reading of the auth middleware,
repository (SQL) layer, HTTP handlers, configuration loader, and Angular auth
service and interceptor.
- **Dynamic testing** — authenticated and unauthenticated requests against the
live API: SQL-injection probes, IDOR attempts, mass-assignment, brute-force
and timing measurement, stored-XSS sink analysis, payload-size and CORS tests.
Destructive tests (e.g. `DROP TABLE` injection) were run against the
development database with immediate cleanup. No third-party or production
systems were touched. All demo data was restored to its seeded state on
completion.
---
## 3. What was tested and found robust
These were actively attacked and **held**; they are recorded so the report is a
fair account, not only a list of faults.
| Control | Test | Result |
| ------- | ---- | ------ |
| SQL injection | `' OR '1'='1`, `'; DROP TABLE bookings;--`, `UNION SELECT` in `origin`/`destination` | Parameterised (`$1…$n`); payloads treated as literal IATA codes, `total=0` |
| Injection via `ORDER BY` | `sort=(SELECT 1)`, `sort=price;DROP` | `orderClause()` whitelists to three fixed clauses; junk falls back to price |
| Mass assignment | POST booking with `status`, `totalAmount`, `bookingReference`, `tenantId` | Rejected 400 — `DisallowUnknownFields()`; price/PNR/tenant are server-derived |
| Cross-tenant read (IDOR) | Fetch another agency's booking/flight by id | 404 `ErrNotFound`; every query filters `tenant_id` from the identity, not the request |
| Credential misuse across tenants | Skyline password against Globetrotter tenant | 401 |
| DoS via large body | 3 MB request body | 400 — `http.MaxBytesReader` caps at 1 MB |
| CORS | Reflect `Origin: https://evil.example.com` | Not reflected; only the configured allow-list origin receives ACAO + credentials |
| Error verbosity | Malformed UUID, malformed JSON | Generic messages; no stack traces or SQL leaked |
| Reflected XSS | Angular templating | No `innerHTML`/`bypassSecurityTrust`/`eval` anywhere; interpolation auto-escapes |
---
## 4. Detailed findings
### FINDING-1 — Application connects to PostgreSQL as a superuser · **HIGH**
**Category:** CWE-250 Execution with Unnecessary Privileges · A04:2021 Insecure Design
**Evidence.** The application connects with the credentials in `backend/.env`
(`DB_USER=postgres`). That role is the cluster superuser:
```
rolname | rolsuper | rolcreatedb | rolcreaterole | rolbypassrls
----------+----------+-------------+---------------+--------------
postgres | t | t | t | t
```
**Impact.** Tenant isolation in TravelHub is enforced entirely in application
code — every repository query filters on `tenant_id`. That is a reasonable
design, but it means the database connection is the last line of defence, and
here it has *no* limits. Any flaw that yields query control (a future missed
`WHERE`, a dynamic-SQL regression, an ORM swap) is not a one-tenant leak but
full read/write/DDL over every tenant, plus `COPY … TO PROGRAM` command
execution and `rolbypassrls` defeating any future row-level security. The
superuser connection silently converts application bugs into total compromise.
**Recommendation.** Create a least-privilege role the app connects as:
```sql
CREATE ROLE travelapp LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE travelhub TO travelapp;
GRANT USAGE ON SCHEMA public TO travelapp;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO travelapp;
-- deliberately NOT: SUPERUSER, CREATEROLE, CREATEDB, TRUNCATE, DDL
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO travelapp;
```
Keep migrations under a separate, higher-privilege role that the running
service never uses.
---
### FINDING-2 — Username enumeration via an inverted timing oracle · **HIGH**
**Category:** CWE-208 Observable Timing Discrepancy · CWE-203 Observable Discrepancy
**Evidence.** `middleware.go` deliberately performs a bcrypt comparison against a
dummy hash when a user is not found, to equalise response time (a correct
instinct). But the dummy hash is **cost 10**:
```go
var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
```
while the real stored hashes are **cost 6** (`002_seed.sql` uses pgcrypto
`gen_salt('bf')`, whose default is 6). Because verifying a cost-10 hash costs
~16× a cost-6 hash, the "user does not exist" path is dramatically *slower* than
the "user exists, wrong password" path. Measured (mean of 30):
```
valid user + wrong password : 5.1 ms
unknown user, valid tenant : 47.3 ms ← ~9x slower
```
**Impact.** An unauthenticated attacker distinguishes valid from invalid
usernames with a single request each, at ~9× separation — a wide, reliable
margin needing no statistics. This enumerates the tenant's user directory
(valid email addresses) for targeted phishing or focused brute force. The
mitigation did not merely fail; it created a *stronger* oracle than doing
nothing, because the two branches now use different work factors.
**Recommendation.** Make the dummy hash's cost identical to the real one, and
pin both. After raising the real cost to 12 (see FINDING-3), regenerate the
dummy at cost 12 so both branches do equal work. Better still, verify against a
per-deployment constant hash generated at startup with the exact configured
cost, so the two can never drift apart again.
---
### FINDING-3 — Bcrypt cost factor below recommended minimum · **MEDIUM**
**Category:** CWE-916 Use of Password Hash With Insufficient Computational Effort
**Evidence.** All stored hashes carry the `$2a$06$` prefix — cost 6:
```
admin@skyline.test | $2a$06$ | 60
agent@skyline.test | $2a$06$ | 60
```
`002_seed.sql` relies on pgcrypto's `gen_salt('bf')` default (6). The test
helper `testsupport.go` uses `bcrypt.MinCost` (4) — acceptable for test speed,
but it means production hashing strength is never exercised by the suite.
**Impact.** Cost 6 is 2⁴ = 16× weaker than the OWASP-recommended cost-10 floor
and 64× weaker than cost 12. Against an offline attacker who obtains the hashes
(see FINDING-1), it materially shortens the time to crack weak and medium
passwords.
**Recommendation.** Hash at **cost 12** in application code rather than in SQL,
so the work factor lives with the app and is consistent. Hash on the Go side
during any user-creation flow (`bcrypt.GenerateFromPassword(pw, 12)`); reseed
demo users accordingly. Support transparent rehash-on-login when an old-cost
hash is encountered.
---
### FINDING-4 — No rate limiting or account lockout on authentication · **MEDIUM**
**Category:** CWE-307 Improper Restriction of Excessive Authentication Attempts · A07:2021
**Evidence.** 25 failed logins for a valid account completed in 144 ms, all
returning 401 — no `429`, no delay, no lockout:
```
25 failed logins in 144 ms -> 25x HTTP 401
```
**Impact.** Online password brute force and credential stuffing are unthrottled.
Combined with FINDING-2 (which hands the attacker the valid usernames) and
FINDING-3 (weaker hashes), the account-takeover path is realistic once exposed
beyond localhost.
**Recommendation.** Add per-source-IP and per-account throttling with
exponential backoff and temporary lockout after N failures. A middleware layer
(e.g. token-bucket keyed on IP + `X-Tenant` + email) fits the existing `Chain`
composition. Return `429` with `Retry-After`. Log repeated failures for
alerting.
---
### FINDING-5 — Tenant enumeration via timing · **MEDIUM**
**Category:** CWE-204 Observable Response Discrepancy
**Evidence.** An unknown *tenant* short-circuits before any password check
(`TenantBySlug` returns `ErrNotFound`, and the handler returns before the bcrypt
call), so it is far faster than a known tenant with bad credentials:
```
unknown tenant : 0.8 ms
valid tenant, wrong pw : 5.1 ms
```
**Impact.** An attacker can discover which agency slugs exist. Lower impact than
FINDING-2 (the public `GET /api/tenants` endpoint already lists active tenants
by design), but it also reveals *suspended* tenants, which the public endpoint
deliberately hides.
**Recommendation.** Perform the dummy bcrypt comparison on the unknown-tenant
path too, so all failure modes cost the same. Fold this into the FINDING-2 fix.
---
### FINDING-6 — Missing HTTP security headers · **MEDIUM**
**Category:** CWE-693 Protection Mechanism Failure · A05:2021 Security Misconfiguration
**Evidence.** API responses carry only `Content-Type`, `Content-Length`, `Date`.
Absent: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
`X-Frame-Options`/`frame-ancestors`, `Content-Security-Policy`,
`Referrer-Policy`, `Cache-Control` on authenticated JSON. The Angular
`index.html` ships no CSP either.
**Impact.** No MIME-sniffing protection, no clickjacking defence, no CSP to
blunt an XSS should one ever be introduced, and browsers may cache
authenticated responses. Individually minor; collectively they remove several
cheap layers of defence.
**Recommendation.** Add a `SecureHeaders` middleware to the `Chain`:
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, `Cache-Control: no-store` on authenticated
responses, and `Strict-Transport-Security` once TLS is terminated. Serve the SPA
with a restrictive `Content-Security-Policy`.
---
### FINDING-7 — Database connections run in plaintext · **MEDIUM**
**Category:** CWE-319 Cleartext Transmission of Sensitive Information
**Evidence.** `backend/.env` and `.env.example` set `DB_SSLMODE=disable`; the
live backend connection reports `PLAINTEXT` via `pg_stat_ssl`.
**Impact.** On localhost, negligible. Once the database is on a separate host or
managed service, credentials and every row (PII: passenger names, passport
numbers, contact details) cross the network unencrypted, exposed to anyone on
the path.
**Recommendation.** Use `sslmode=verify-full` with a pinned CA for any non-local
database. Keep `disable` only for the loopback dev setup, and document that the
production value must differ.
---
### FINDING-8 — Real database password committed in test source · **LOW**
**Category:** CWE-798 Use of Hard-coded Credentials
**Evidence.** `backend/internal/config/config_test.go` contains the actual
development database password (redacted here; it was the live value from
`backend/.env`) as a test literal for the
DSN-escaping test.
**Impact.** Low today (dev password, not yet a git repository), but it will be
committed on first `git init`/push and then lives in history forever, including
if this password is reused elsewhere. Secrets in test fixtures are a common
real-world leak vector.
**Recommendation.** Replace with a synthetic password that still exercises the
escaping logic (e.g. `p@ss:w/rd?#%` — the test already covers such cases). Never
place a real, in-use secret in tracked source. Add a secret-scanning pre-commit
hook (e.g. gitleaks) before the first commit.
---
### FINDING-9 — Credentials stored reversibly client-side and replayed · **LOW**
**Category:** CWE-522 Insufficiently Protected Credentials · CWE-312 Cleartext Storage
**Evidence.** `auth.service.ts` stores `btoa(email:password)` in `localStorage`
and the interceptor attaches it to every API call. base64 is encoding, not
encryption — the plaintext password is trivially recoverable from the browser.
**Impact.** This is inherent to HTTP Basic auth, and the README already flags it.
The concrete risk: any XSS (or a malicious/compromised dependency in the SPA)
reads the reusable password straight out of `localStorage` — worse than a
short-lived token would be — and the credential travels on every single request.
**Recommendation.** As the README foresees, move to short-lived tokens (JWT or
opaque session) with refresh, delivered in an `HttpOnly`, `Secure`, `SameSite`
cookie so client script cannot read them. The auth logic is already isolated to
`internal/middleware` + `internal/auth` and the Angular `auth.service`, so the
blast radius of the change is small.
---
### FINDING-10 — No server-side output encoding of stored data · **LOW**
**Category:** CWE-79 (stored-XSS sink, currently unreachable)
**Evidence.** A passenger created with `firstName =
<script>alert(document.cookie)</script>` is stored and returned verbatim by the
API.
**Impact.** **Not currently exploitable** — every consumer is the Angular SPA,
which output-encodes on interpolation, and no `innerHTML` sink exists. The risk
is latent: a future consumer that renders this data as HTML (an email
confirmation, a PDF ticket, an admin tool, a report export) would execute it. It
is recorded as defence-in-depth.
**Recommendation.** Treat the API as the trust boundary: validate/normalise
free-text fields on input (reject or strip control characters and markup where a
name field cannot legitimately contain them), and ensure any non-Angular
renderer of this data encodes for its context. Do not rely solely on the
current client escaping.
---
### FINDING-11 — Role model implemented but unenforced · **INFO**
**Category:** CWE-1220 Insufficient Granularity of Access Control (latent)
**Evidence.** `RequireAdmin` middleware exists and is unit-tested, and users
carry an `admin`/`agent` role, but `router.go` gates **no** route with it — every
authenticated user reaches every endpoint.
**Impact.** None today: there is no admin-only functionality to protect, so
`agent` and `admin` are functionally identical. Flagged because the moment an
admin-only capability is added (tenant settings, user management, refunds), it
must be remembered to attach `RequireAdmin`, or it will be silently open to all
agents. This is a broken-access-control regression waiting to happen.
**Recommendation.** When admin functionality is introduced, gate it explicitly
and add a test asserting an `agent` receives 403 — mirroring the existing
`TestRequireAdmin`. Consider a default-deny posture where new routes are
explicitly assigned a required role.
---
## 5. Remediation priority
| Priority | Findings | Rationale |
| -------- | -------- | --------- |
| **Do before any non-local deployment** | 1, 2, 4, 6, 7 | These convert from low-risk to serious the instant the app leaves localhost |
| **Do soon** | 3, 5, 8 | Cheap fixes that raise the cost of the attack paths above |
| **Do as the design evolves** | 9, 10, 11 | Tie to the planned auth change and to new features |
A single focused change set closes most of the high/medium items: introduce a
least-privilege DB role (1), add `SecureHeaders` + rate-limiting middleware to
the existing `Chain` (4, 6), align bcrypt cost and the dummy hash and move
hashing into Go (2, 3, 5), and flip `sslmode` for non-local (7).
---
## 6. Methodology notes & limitations
- Testing was against a single-node localhost deployment with seeded data. A
production deployment behind a reverse proxy / WAF / TLS terminator may
already mitigate some network-layer findings (6, 7) — verify against the real
topology.
- Timing measurements were taken over loopback (mean of 30 requests); absolute
values will differ over a network, but the *relative* discrepancies that
constitute FINDING-2 and FINDING-5 are structural (different work factors /
short-circuit paths) and will persist.
- No load, fuzzing, or dependency-CVE (SCA) scanning was performed; recommend
adding `govulncheck` (Go) and `npm audit`/OWASP Dependency-Check (frontend)
to CI as a follow-up.
- Scope was the application. Host hardening, PostgreSQL configuration
(`pg_hba.conf`, listen addresses), and OS patching were out of scope.
```
+364
View File
@@ -0,0 +1,364 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TravelHub — Security Assessment Report</title>
<style>
:root{
--ink:#111827;--body:#374151;--muted:#6b7280;--faint:#9ca3af;
--line:#e5e7eb;--rule:#d1d5db;--bg:#f3f4f6;--card:#fff;--accent:#0b5cd6;
--high:#b3243f;--high-bg:#fdeaee;--med:#b45309;--med-bg:#fef3c7;
--low:#0b5cd6;--low-bg:#eaf1fd;--info:#6b7280;--info-bg:#f3f4f6;
--ok:#067a6e;--ok-bg:#e6f4f1;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--body);
font:15px/1.62 'Inter',-apple-system,'Segoe UI',Roboto,sans-serif}
.sheet{max-width:1000px;margin:0 auto;background:var(--card);box-shadow:0 1px 3px rgba(0,0,0,.08)}
h1,h2,h3,h4{color:var(--ink);margin:0;line-height:1.3}
a{color:var(--accent)}
code{background:var(--bg);padding:1px 5px;border-radius:3px;font-size:.86em}
pre{background:#0f172a;color:#e2e8f0;padding:13px 16px;border-radius:8px;overflow-x:auto;
font:12.5px/1.5 ui-monospace,'SFMono-Regular',Menlo,monospace;margin:12px 0}
pre.sql .k{color:#93c5fd}
.cover{padding:44px 56px 34px;border-bottom:3px solid var(--ink)}
.eyebrow{font-size:.72rem;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);
font-weight:650;margin-bottom:10px}
.cover h1{font-size:2rem;letter-spacing:-.025em;margin-bottom:6px}
.cover .sub{color:var(--muted);font-size:.92rem;margin:0}
.verdict{display:flex;gap:16px;align-items:flex-start;margin-top:26px;padding:18px 22px;
border-radius:10px;background:var(--ok-bg);border:1px solid #b9e0d8}
.verdict .mark{width:40px;height:40px;border-radius:50%;background:var(--ok);color:#fff;
display:grid;place-items:center;font-size:1.2rem;flex-shrink:0}
.verdict .h{font-size:1.08rem;font-weight:650;color:var(--ink)}
.verdict .t{font-size:.9rem;color:var(--muted)}
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
border-top:1px solid var(--line);margin-top:26px}
.facts div{padding:13px 22px 13px 0;border-bottom:1px solid var(--line)}
.facts dt{font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);
font-weight:650;margin-bottom:3px}
.facts dd{margin:0;font-size:.9rem;color:var(--ink)}
section{padding:34px 56px}
section+section{border-top:1px solid var(--line)}
.stitle{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;flex-wrap:wrap}
.stitle .n{color:var(--faint);font-variant-numeric:tabular-nums;font-weight:650;font-size:1.05rem}
.stitle h2{font-size:1.3rem;letter-spacing:-.015em}
.lede{color:var(--muted);max-width:76ch}
.sev{display:inline-block;font-size:.66rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
padding:3px 9px;border-radius:4px;white-space:nowrap}
.sev.high{background:var(--high-bg);color:var(--high)}
.sev.med{background:var(--med-bg);color:var(--med)}
.sev.low{background:var(--low-bg);color:var(--low)}
.sev.info{background:var(--info-bg);color:var(--info)}
.sev.ok{background:var(--ok-bg);color:var(--ok)}
table{width:100%;border-collapse:collapse;font-size:.9rem;margin:6px 0}
th{text-align:left;font-size:.7rem;letter-spacing:.07em;text-transform:uppercase;color:var(--faint);
font-weight:650;padding:0 10px 8px 0;border-bottom:2px solid var(--rule)}
td{padding:10px 10px 10px 0;border-bottom:1px solid var(--line);vertical-align:top}
td.c{white-space:nowrap;font-variant-numeric:tabular-nums}
tr td:first-child{color:var(--faint);font-variant-numeric:tabular-nums;width:34px}
.finding{border:1px solid var(--line);border-left-width:4px;border-radius:8px;
padding:20px 24px;margin-bottom:18px;background:var(--card)}
.finding.high{border-left-color:var(--high)}
.finding.med{border-left-color:var(--med)}
.finding.low{border-left-color:var(--low)}
.finding.info{border-left-color:var(--info)}
.finding h3{font-size:1.06rem;margin-bottom:4px;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
.finding .id{color:var(--faint);font-size:.8rem;font-weight:650;font-variant-numeric:tabular-nums}
.cwe{font-size:.78rem;color:var(--muted);margin-bottom:14px}
.finding h4{font-size:.8rem;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);
margin:16px 0 6px}
.finding p{margin:0 0 8px}
.rec{background:var(--ok-bg);border:1px solid #b9e0d8;border-radius:7px;padding:12px 15px;margin-top:14px}
.rec h4{color:var(--ok);margin-top:0}
.robust td:first-child{color:var(--faint)}
.pill{font-size:.7rem;font-weight:700;color:var(--ok);background:var(--ok-bg);
padding:2px 8px;border-radius:99px}
footer{padding:24px 56px 40px;color:var(--faint);font-size:.82rem;border-top:1px solid var(--line)}
@media (max-width:720px){.cover{padding:26px 20px}section{padding:24px 20px}footer{padding:20px}}
@media print{
body{background:#fff}.sheet{box-shadow:none;max-width:none}
.cover{padding:0 0 20px}section{padding:22px 0}footer{padding:14px 0 0}
.finding{break-inside:avoid}section{break-inside:avoid-page}
h2,h3{break-after:avoid}pre{break-inside:avoid;white-space:pre-wrap}
a{color:var(--ink);text-decoration:none}
}
</style>
</head>
<body>
<div class="sheet">
<div class="cover">
<div class="eyebrow">Security assessment · Whitebox VAPT</div>
<h1>TravelHub</h1>
<p class="sub">Vulnerability Assessment &amp; Penetration Test &middot; Authorised whitebox engagement<br />
Multi-tenant flight booking — Go API, Angular SPA, PostgreSQL &middot; 12 September 2026</p>
<div class="verdict">
<span class="mark">&#10003;</span>
<span>
<span class="h">Core access controls held; findings are operational and defence-in-depth</span><br />
<span class="t">Tenant isolation, SQL parameterisation, mass-assignment and CORS defences all resisted direct attack.
2 High, 5 Medium, 3 Low and 1 Informational finding were identified, chiefly around
credential handling, least privilege and transport hardening.</span>
</span>
</div>
<dl class="facts">
<div><dt>Engagement</dt><dd>Whitebox (source-assisted)</dd></div>
<div><dt>Targets</dt><dd>API :8080 · SPA :4200 · PostgreSQL 17</dd></div>
<div><dt>Findings</dt><dd>2 High · 5 Medium · 3 Low · 1 Info</dd></div>
<div><dt>Critical / RCE (pre-auth)</dt><dd>None</dd></div>
<div><dt>Method</dt><dd>Static review + dynamic testing</dd></div>
<div><dt>Data handling</dt><dd>Dev DB only; demo data restored</dd></div>
</dl>
</div>
<!-- 1. Executive summary -->
<section>
<div class="stitle"><span class="n">1</span><h2>Executive summary</h2></div>
<p class="lede">
TravelHub is architecturally sound on the issues that most often sink a multi-tenant
application. Tenant isolation held under direct testing, SQL is parameterised throughout, the
JSON decoder rejects unknown fields, request bodies are size-capped, and the Angular client
escapes interpolated data by default. The weaknesses found are <strong>operational and
defence-in-depth</strong>, not broken access control.
</p>
<p class="lede" style="margin-top:12px">
The two most important are a <strong>database superuser connection</strong>, which turns any
application compromise into total database control and nullifies the application-layer tenant
isolation, and a <strong>timing side-channel that backfired</strong>: the mitigation intended to
hide which usernames exist actually makes them <em>more</em> distinguishable. None of the
findings is a remotely exploitable pre-authentication breach on the current localhost
deployment; several become serious the moment the app is exposed beyond localhost.
</p>
<table>
<thead><tr><th>#</th><th>Severity</th><th>Finding</th></tr></thead>
<tbody>
<tr><td>1</td><td><span class="sev high">High</span></td><td>Application connects to PostgreSQL as a superuser (no least privilege)</td></tr>
<tr><td>2</td><td><span class="sev high">High</span></td><td>Username enumeration via a timing oracle — equalisation mitigation is miscalibrated and inverts</td></tr>
<tr><td>3</td><td><span class="sev med">Medium</span></td><td>Bcrypt cost factor 6, below the OWASP-recommended minimum of 10</td></tr>
<tr><td>4</td><td><span class="sev med">Medium</span></td><td>No rate limiting or account lockout on authentication</td></tr>
<tr><td>5</td><td><span class="sev med">Medium</span></td><td>Tenant enumeration via timing (unknown tenant short-circuits before password check)</td></tr>
<tr><td>6</td><td><span class="sev med">Medium</span></td><td>No HTTP security headers (HSTS, nosniff, frame-options, CSP…)</td></tr>
<tr><td>7</td><td><span class="sev med">Medium</span></td><td>Database connections run in plaintext (<code>sslmode=disable</code>)</td></tr>
<tr><td>8</td><td><span class="sev low">Low</span></td><td>Real database password committed in <code>config_test.go</code></td></tr>
<tr><td>9</td><td><span class="sev low">Low</span></td><td>Credentials held reversibly in localStorage and replayed each request</td></tr>
<tr><td>10</td><td><span class="sev low">Low</span></td><td>API stores markup verbatim — no server-side output encoding (defence-in-depth)</td></tr>
<tr><td>11</td><td><span class="sev info">Info</span></td><td><code>RequireAdmin</code> middleware wired to no route; role model unenforced</td></tr>
</tbody>
</table>
</section>
<!-- 2. Robust controls -->
<section>
<div class="stitle"><span class="n">2</span><h2>Controls that resisted attack</h2></div>
<p class="lede">These were actively attacked and held. Recorded so the report is a fair account, not only a list of faults.</p>
<table class="robust">
<thead><tr><th>Control</th><th>Test performed</th><th>Result</th></tr></thead>
<tbody>
<tr><td>SQL injection</td><td><code>' OR '1'='1</code>, <code>'; DROP TABLE bookings;--</code>, UNION in search params</td><td><span class="pill">Parameterised</span></td></tr>
<tr><td>ORDER BY injection</td><td><code>sort=(SELECT 1)</code>, <code>sort=price;DROP</code></td><td><span class="pill">Whitelisted</span></td></tr>
<tr><td>Mass assignment</td><td>POST booking with status, totalAmount, bookingReference, tenantId</td><td><span class="pill">Rejected 400</span></td></tr>
<tr><td>Cross-tenant read (IDOR)</td><td>Fetch another agency's booking / flight by id</td><td><span class="pill">404 not-found</span></td></tr>
<tr><td>Cross-tenant credentials</td><td>Skyline password against Globetrotter tenant</td><td><span class="pill">401</span></td></tr>
<tr><td>Large-body DoS</td><td>3&nbsp;MB request body</td><td><span class="pill">400 — 1MB cap</span></td></tr>
<tr><td>CORS</td><td>Reflect <code>Origin: evil.example.com</code></td><td><span class="pill">Not reflected</span></td></tr>
<tr><td>Error verbosity</td><td>Malformed UUID / JSON</td><td><span class="pill">Generic</span></td></tr>
<tr><td>Reflected XSS</td><td>Angular template sinks</td><td><span class="pill">Auto-escaped</span></td></tr>
</tbody>
</table>
</section>
<!-- 3. Detailed findings -->
<section>
<div class="stitle"><span class="n">3</span><h2>Detailed findings</h2></div>
<div class="finding high">
<h3><span class="id">FINDING-1</span> Application connects to PostgreSQL as a superuser <span class="sev high">High</span></h3>
<div class="cwe">CWE-250 Execution with Unnecessary Privileges · OWASP A04:2021 Insecure Design</div>
<h4>Evidence</h4>
<p>The application connects with the <code>backend/.env</code> credentials (<code>DB_USER=postgres</code>), which is the cluster superuser:</p>
<pre>rolname | rolsuper | rolcreatedb | rolcreaterole | rolbypassrls
postgres | t | t | t | t</pre>
<h4>Impact</h4>
<p>Tenant isolation is enforced entirely in application code (every query filters <code>tenant_id</code>), so the DB connection is the last line of defence — and it has none. Any query-control flaw becomes full read/write/DDL over <em>every</em> tenant, plus <code>COPY … TO PROGRAM</code> command execution and <code>rolbypassrls</code> defeating any future row-level security. The superuser connection silently converts application bugs into total compromise.</p>
<div class="rec"><h4>Recommendation</h4>
<p style="margin-bottom:6px">Connect as a least-privilege role; keep migrations under a separate higher-privilege role the service never uses.</p>
<pre>CREATE ROLE travelapp LOGIN PASSWORD '…';
GRANT CONNECT ON DATABASE travelhub TO travelapp;
GRANT USAGE ON SCHEMA public TO travelapp;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO travelapp;
-- NOT: SUPERUSER, CREATEROLE, CREATEDB, TRUNCATE, DDL</pre>
</div>
</div>
<div class="finding high">
<h3><span class="id">FINDING-2</span> Username enumeration via an inverted timing oracle <span class="sev high">High</span></h3>
<div class="cwe">CWE-208 Observable Timing Discrepancy · CWE-203 Observable Discrepancy</div>
<h4>Evidence</h4>
<p>The auth middleware runs a dummy bcrypt comparison for unknown users to equalise timing — a correct instinct — but the dummy hash is <strong>cost 10</strong> while the real stored hashes are <strong>cost 6</strong>. Verifying a cost-10 hash costs ~16× a cost-6 hash, so the "user does not exist" path is far <em>slower</em>. Measured (mean of 30):</p>
<pre>valid user + wrong password : 5.1 ms
unknown user, valid tenant : 47.3 ms &larr; ~9x slower</pre>
<h4>Impact</h4>
<p>An unauthenticated attacker distinguishes valid from invalid usernames with a single request each, at a ~9× margin needing no statistics — enumerating the tenant's user directory for phishing or focused brute force. The mitigation did not merely fail; it created a <em>stronger</em> oracle than doing nothing, because the two branches now use different work factors.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Make the dummy hash's cost identical to the real one and pin both. Better: verify against a per-deployment constant hash generated at startup with the exact configured cost, so the two branches can never drift apart.</p></div>
</div>
<div class="finding med">
<h3><span class="id">FINDING-3</span> Bcrypt cost factor below recommended minimum <span class="sev med">Medium</span></h3>
<div class="cwe">CWE-916 Password Hash With Insufficient Computational Effort</div>
<h4>Evidence</h4>
<p>All stored hashes carry the <code>$2a$06$</code> prefix (cost 6); <code>002_seed.sql</code> relies on pgcrypto <code>gen_salt('bf')</code>, whose default is 6. The test helper uses <code>bcrypt.MinCost</code> (4), so production hashing strength is never exercised by the suite.</p>
<h4>Impact</h4>
<p>Cost 6 is 16× weaker than the OWASP cost-10 floor and 64× weaker than cost 12. Against an offline attacker who obtains the hashes (see Finding&nbsp;1) it materially shortens crack time.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Hash at <strong>cost 12 in Go application code</strong> (<code>bcrypt.GenerateFromPassword(pw, 12)</code>) rather than in SQL, so the work factor lives with the app and stays consistent. Support rehash-on-login for old hashes.</p></div>
</div>
<div class="finding med">
<h3><span class="id">FINDING-4</span> No rate limiting or account lockout on authentication <span class="sev med">Medium</span></h3>
<div class="cwe">CWE-307 Improper Restriction of Excessive Authentication Attempts · A07:2021</div>
<h4>Evidence</h4>
<pre>25 failed logins in 144 ms &rarr; 25x HTTP 401 (no 429, no delay, no lockout)</pre>
<h4>Impact</h4>
<p>Online brute force and credential stuffing are unthrottled. Combined with Finding&nbsp;2 (valid usernames handed to the attacker) and Finding&nbsp;3 (weaker hashes), account takeover is realistic once exposed beyond localhost.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Add per-IP and per-account throttling with exponential backoff and temporary lockout, as a middleware in the existing <code>Chain</code> (token-bucket keyed on IP + <code>X-Tenant</code> + email). Return <code>429</code> with <code>Retry-After</code>; log repeated failures for alerting.</p></div>
</div>
<div class="finding med">
<h3><span class="id">FINDING-5</span> Tenant enumeration via timing <span class="sev med">Medium</span></h3>
<div class="cwe">CWE-204 Observable Response Discrepancy</div>
<h4>Evidence</h4>
<p>An unknown tenant short-circuits before any password check, so it is far faster than a known tenant with bad credentials:</p>
<pre>unknown tenant : 0.8 ms
valid tenant, wrong pw : 5.1 ms</pre>
<h4>Impact</h4>
<p>Reveals which agency slugs exist — including <em>suspended</em> tenants, which the public <code>GET /api/tenants</code> endpoint deliberately hides. Lower impact than Finding&nbsp;2 since active tenants are already public by design.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Perform the dummy bcrypt comparison on the unknown-tenant path too, so all failure modes cost the same. Fold into the Finding&nbsp;2 fix.</p></div>
</div>
<div class="finding med">
<h3><span class="id">FINDING-6</span> Missing HTTP security headers <span class="sev med">Medium</span></h3>
<div class="cwe">CWE-693 Protection Mechanism Failure · A05:2021 Security Misconfiguration</div>
<h4>Evidence</h4>
<p>API responses carry only <code>Content-Type</code>, <code>Content-Length</code>, <code>Date</code>. Absent: <code>Strict-Transport-Security</code>, <code>X-Content-Type-Options</code>, <code>X-Frame-Options</code>, <code>Content-Security-Policy</code>, <code>Referrer-Policy</code>, and <code>Cache-Control</code> on authenticated JSON. The SPA ships no CSP.</p>
<h4>Impact</h4>
<p>No MIME-sniffing protection, no clickjacking defence, no CSP to blunt a future XSS, and authenticated responses may be cached. Individually minor; collectively they remove several cheap layers.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Add a <code>SecureHeaders</code> middleware to the <code>Chain</code>: <code>nosniff</code>, <code>X-Frame-Options: DENY</code>, <code>Referrer-Policy: no-referrer</code>, <code>Cache-Control: no-store</code> on authenticated responses, and HSTS once TLS terminates. Serve the SPA with a restrictive CSP.</p></div>
</div>
<div class="finding med">
<h3><span class="id">FINDING-7</span> Database connections run in plaintext <span class="sev med">Medium</span></h3>
<div class="cwe">CWE-319 Cleartext Transmission of Sensitive Information</div>
<h4>Evidence</h4>
<p><code>.env</code> sets <code>DB_SSLMODE=disable</code>; the live connection reports <code>PLAINTEXT</code> via <code>pg_stat_ssl</code>.</p>
<h4>Impact</h4>
<p>Negligible on localhost. Once the database is on a separate host, credentials and PII (passenger names, passport numbers, contact details) cross the network unencrypted.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Use <code>sslmode=verify-full</code> with a pinned CA for any non-local database; keep <code>disable</code> only for loopback dev, and document that production must differ.</p></div>
</div>
<div class="finding low">
<h3><span class="id">FINDING-8</span> Real database password committed in test source <span class="sev low">Low</span></h3>
<div class="cwe">CWE-798 Use of Hard-coded Credentials</div>
<h4>Evidence</h4>
<p><code>config_test.go</code> contains the actual development DB password as a test literal for the DSN-escaping test.</p>
<h4>Impact</h4>
<p>Low today (dev password, not yet a git repo), but it will enter git history on first push and persist forever, especially damaging if the password is reused. Secrets in fixtures are a common leak vector.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Use a synthetic password that still exercises escaping (e.g. <code>p@ss:w/rd?#%</code>). Add a secret-scanning pre-commit hook (gitleaks) before the first commit.</p></div>
</div>
<div class="finding low">
<h3><span class="id">FINDING-9</span> Credentials stored reversibly client-side and replayed <span class="sev low">Low</span></h3>
<div class="cwe">CWE-522 Insufficiently Protected Credentials · CWE-312 Cleartext Storage</div>
<h4>Evidence</h4>
<p><code>auth.service.ts</code> stores <code>btoa(email:password)</code> in <code>localStorage</code> and replays it on every request. base64 is encoding, not encryption — the plaintext password is trivially recoverable.</p>
<h4>Impact</h4>
<p>Inherent to HTTP Basic auth (the README flags it). Concrete risk: any XSS or compromised SPA dependency reads the reusable password from <code>localStorage</code> — worse than a short-lived token — and it travels on every request.</p>
<div class="rec"><h4>Recommendation</h4>
<p>As the README foresees, move to short-lived tokens with refresh in an <code>HttpOnly; Secure; SameSite</code> cookie. Auth logic is already isolated to two backend packages and the Angular service, so the change is contained.</p></div>
</div>
<div class="finding low">
<h3><span class="id">FINDING-10</span> No server-side output encoding of stored data <span class="sev low">Low</span></h3>
<div class="cwe">CWE-79 Stored XSS sink (currently unreachable)</div>
<h4>Evidence</h4>
<p>A passenger created with <code>firstName = &lt;script&gt;alert(document.cookie)&lt;/script&gt;</code> is stored and returned verbatim by the API.</p>
<h4>Impact</h4>
<p><strong>Not currently exploitable</strong> — the only consumer is Angular, which output-encodes, and no <code>innerHTML</code> sink exists. Latent: a future consumer rendering this as HTML (email, PDF ticket, admin tool, export) would execute it.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Treat the API as the trust boundary: normalise/validate free-text on input and ensure any non-Angular renderer encodes for its context. Do not rely solely on client escaping.</p></div>
</div>
<div class="finding info">
<h3><span class="id">FINDING-11</span> Role model implemented but unenforced <span class="sev info">Info</span></h3>
<div class="cwe">CWE-1220 Insufficient Granularity of Access Control (latent)</div>
<h4>Evidence</h4>
<p><code>RequireAdmin</code> exists and is unit-tested, and users carry <code>admin</code>/<code>agent</code> roles, but <code>router.go</code> gates no route with it — every authenticated user reaches every endpoint.</p>
<h4>Impact</h4>
<p>None today (no admin-only functionality). Flagged because the moment one is added it must be remembered to attach <code>RequireAdmin</code>, or it will be silently open to all agents — a broken-access-control regression waiting to happen.</p>
<div class="rec"><h4>Recommendation</h4>
<p>Gate admin functionality explicitly when introduced and add a test asserting an <code>agent</code> receives 403, mirroring the existing <code>TestRequireAdmin</code>. Prefer a default-deny posture for new routes.</p></div>
</div>
</section>
<!-- 4. Remediation -->
<section>
<div class="stitle"><span class="n">4</span><h2>Remediation priority</h2></div>
<table>
<thead><tr><th>Priority</th><th>Findings</th><th>Rationale</th></tr></thead>
<tbody>
<tr><td class="c"><strong>Before any non-local deployment</strong></td><td class="c">1, 2, 4, 6, 7</td><td>These turn from low-risk to serious the instant the app leaves localhost</td></tr>
<tr><td class="c"><strong>Soon</strong></td><td class="c">3, 5, 8</td><td>Cheap fixes that raise the cost of the attack paths above</td></tr>
<tr><td class="c"><strong>As the design evolves</strong></td><td class="c">9, 10, 11</td><td>Tie to the planned auth change and to new features</td></tr>
</tbody>
</table>
<p class="lede" style="margin-top:14px">
A single focused change set closes most high/medium items: a least-privilege DB role (1);
<code>SecureHeaders</code> + rate-limiting middleware on the existing <code>Chain</code> (4, 6);
aligned bcrypt cost with hashing moved into Go (2, 3, 5); and <code>sslmode</code> flipped for
non-local (7).
</p>
</section>
<!-- 5. Method & limitations -->
<section>
<div class="stitle"><span class="n">5</span><h2>Methodology &amp; limitations</h2></div>
<ul class="lede" style="padding-left:20px">
<li>Testing was against a single-node localhost deployment with seeded data. A production
topology behind a reverse proxy / WAF / TLS terminator may already mitigate some
network-layer findings (6, 7) — verify against the real deployment.</li>
<li>Timing was measured over loopback (mean of 30 requests); absolute values differ over a
network, but the <em>relative</em> discrepancies behind Findings 2 and 5 are structural
(different work factors / short-circuit paths) and persist.</li>
<li>No load, fuzzing, or dependency-CVE (SCA) scanning was performed. Recommend adding
<code>govulncheck</code> and <code>npm audit</code> / OWASP Dependency-Check to CI as a follow-up.</li>
<li>Scope was the application. Host hardening, PostgreSQL <code>pg_hba.conf</code> / listen
configuration, and OS patching were out of scope.</li>
</ul>
</section>
<footer>
Authorised whitebox VAPT of the TravelHub codebase and its localhost instances. Findings are the
assessor's own; verify each against your target environment before and after remediation.
Print this page (or use <code>npm run report</code>-style export) to produce a PDF.
</footer>
</div>
</body>
</html>