Vulnerability Assessment & Penetration Test · Authorised whitebox engagement
Multi-tenant flight booking — Go API, Angular SPA, PostgreSQL · 12 September 2026
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 operational and defence-in-depth, not broken access control.
The two most important are a database superuser connection, which turns any application compromise into total database control and nullifies the 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 the app is exposed beyond localhost.
| # | Severity | Finding |
|---|---|---|
| 1 | High | Application connects to PostgreSQL as a superuser (no least privilege) |
| 2 | High | Username enumeration via a timing oracle — 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, nosniff, 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 each request |
| 10 | Low | API stores markup verbatim — no server-side output encoding (defence-in-depth) |
| 11 | Info | RequireAdmin middleware wired to no route; role model unenforced |
These were actively attacked and held. Recorded so the report is a fair account, not only a list of faults.
| Control | Test performed | Result |
|---|---|---|
| SQL injection | ' OR '1'='1, '; DROP TABLE bookings;--, UNION in search params | Parameterised |
| ORDER BY injection | sort=(SELECT 1), sort=price;DROP | Whitelisted |
| Mass assignment | POST booking with status, totalAmount, bookingReference, tenantId | Rejected 400 |
| Cross-tenant read (IDOR) | Fetch another agency's booking / flight by id | 404 not-found |
| Cross-tenant credentials | Skyline password against Globetrotter tenant | 401 |
| Large-body DoS | 3 MB request body | 400 — 1MB cap |
| CORS | Reflect Origin: evil.example.com | Not reflected |
| Error verbosity | Malformed UUID / JSON | Generic |
| Reflected XSS | Angular template sinks | Auto-escaped |
The application connects with the backend/.env credentials (DB_USER=postgres), which is the cluster superuser:
rolname | rolsuper | rolcreatedb | rolcreaterole | rolbypassrls postgres | t | t | t | t
Tenant isolation is enforced entirely in application code (every query filters tenant_id), so the DB connection is the last line of defence — and it has none. Any query-control flaw becomes 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.
Connect as a least-privilege role; keep migrations under a separate higher-privilege role the service never uses.
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
The auth middleware runs a dummy bcrypt comparison for unknown users to equalise timing — a correct instinct — but the dummy hash is cost 10 while the real stored hashes are cost 6. Verifying a cost-10 hash costs ~16× a cost-6 hash, so the "user does not exist" path is far slower. Measured (mean of 30):
valid user + wrong password : 5.1 ms unknown user, valid tenant : 47.3 ms ← ~9x slower
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 stronger oracle than doing nothing, because the two branches now use different work factors.
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.
All stored hashes carry the $2a$06$ prefix (cost 6); 002_seed.sql relies on pgcrypto gen_salt('bf'), whose default is 6. The test helper uses bcrypt.MinCost (4), so production hashing strength is never exercised by the suite.
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 1) it materially shortens crack time.
Hash at cost 12 in Go application code (bcrypt.GenerateFromPassword(pw, 12)) rather than in SQL, so the work factor lives with the app and stays consistent. Support rehash-on-login for old hashes.
25 failed logins in 144 ms → 25x HTTP 401 (no 429, no delay, no lockout)
Online brute force and credential stuffing are unthrottled. Combined with Finding 2 (valid usernames handed to the attacker) and Finding 3 (weaker hashes), account takeover is realistic once exposed beyond localhost.
Add per-IP and per-account throttling with exponential backoff and temporary lockout, as a middleware in the existing Chain (token-bucket keyed on IP + X-Tenant + email). Return 429 with Retry-After; log repeated failures for alerting.
An unknown tenant short-circuits before any password check, so it is far faster than a known tenant with bad credentials:
unknown tenant : 0.8 ms valid tenant, wrong pw : 5.1 ms
Reveals which agency slugs exist — including suspended tenants, which the public GET /api/tenants endpoint deliberately hides. Lower impact than Finding 2 since active tenants are already public by design.
Perform the dummy bcrypt comparison on the unknown-tenant path too, so all failure modes cost the same. Fold into the Finding 2 fix.
API responses carry only Content-Type, Content-Length, Date. Absent: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy, and Cache-Control on authenticated JSON. The SPA ships no CSP.
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.
Add a SecureHeaders middleware to the Chain: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store on authenticated responses, and HSTS once TLS terminates. Serve the SPA with a restrictive CSP.
.env sets DB_SSLMODE=disable; the live connection reports PLAINTEXT via pg_stat_ssl.
Negligible on localhost. Once the database is on a separate host, credentials and PII (passenger names, passport numbers, contact details) cross the network unencrypted.
Use sslmode=verify-full with a pinned CA for any non-local database; keep disable only for loopback dev, and document that production must differ.
config_test.go contains the actual development DB password as a test literal for the DSN-escaping test.
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.
Use a synthetic password that still exercises escaping (e.g. p@ss:w/rd?#%). Add a secret-scanning pre-commit hook (gitleaks) before the first commit.
auth.service.ts stores btoa(email:password) in localStorage and replays it on every request. base64 is encoding, not encryption — the plaintext password is trivially recoverable.
Inherent to HTTP Basic auth (the README flags it). Concrete risk: any XSS or compromised SPA dependency reads the reusable password from localStorage — worse than a short-lived token — and it travels on every request.
As the README foresees, move to short-lived tokens with refresh in an HttpOnly; Secure; SameSite cookie. Auth logic is already isolated to two backend packages and the Angular service, so the change is contained.
A passenger created with firstName = <script>alert(document.cookie)</script> is stored and returned verbatim by the API.
Not currently exploitable — the only consumer is Angular, which output-encodes, and no innerHTML sink exists. Latent: a future consumer rendering this as HTML (email, PDF ticket, admin tool, export) would execute it.
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.
RequireAdmin exists and is unit-tested, and users carry admin/agent roles, but router.go gates no route with it — every authenticated user reaches every endpoint.
None today (no admin-only functionality). Flagged because the moment one is added it must be remembered to attach RequireAdmin, or it will be silently open to all agents — a broken-access-control regression waiting to happen.
Gate admin functionality explicitly when introduced and add a test asserting an agent receives 403, mirroring the existing TestRequireAdmin. Prefer a default-deny posture for new routes.
| Priority | Findings | Rationale |
|---|---|---|
| Before any non-local deployment | 1, 2, 4, 6, 7 | These turn from low-risk to serious the instant the app leaves localhost |
| Soon | 3, 5, 8 | Cheap fixes that raise the cost of the attack paths above |
| As the design evolves | 9, 10, 11 | Tie to the planned auth change and to new features |
A single focused change set closes most high/medium items: a least-privilege DB role (1);
SecureHeaders + rate-limiting middleware on the existing Chain (4, 6);
aligned bcrypt cost with hashing moved into Go (2, 3, 5); and sslmode flipped for
non-local (7).
govulncheck and npm audit / OWASP Dependency-Check to CI as a follow-up.pg_hba.conf / listen
configuration, and OS patching were out of scope.