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:
Binary file not shown.
@@ -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.
|
||||
```
|
||||
@@ -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 & Penetration Test · Authorised whitebox engagement<br />
|
||||
Multi-tenant flight booking — Go API, Angular SPA, PostgreSQL · 12 September 2026</p>
|
||||
|
||||
<div class="verdict">
|
||||
<span class="mark">✓</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 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 ← ~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 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 → 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 2 (valid usernames handed to the attacker) and Finding 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 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 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 = <script>alert(document.cookie)</script></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 & 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>
|
||||
Reference in New Issue
Block a user