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>
18 KiB
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:
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:
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 theAPI.
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) andnpm 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.