
From Finding to Fix: When Is a Penetration Test Finding Really Remediated?
An authenticated customer changes the invoice ID in a download URL and receives a PDF belonging to another tenant. The vulnerability can be reproduced within minutes. The development team adds an authorisation check to the controller; the request from the penetration test report now fails. The ticket is closed.
Four weeks later, the same invoice can still be retrieved through an export endpoint.
The example is fictional, but the underlying problem is realistic: the patch protects one endpoint, while the application still enforces object-level authorisation inconsistently. Initially, the penetration test report shows only the demonstrated attack path. To remediate the issue, the team must determine where the authorisation decision is made incorrectly or not at all.
A Closed Ticket Can Mean Four Different Things
In practice, several different states are quickly grouped together under the label “remediated”:
- The specific proof of concept no longer works.
- The vulnerability has been fixed at the documented endpoint.
- The underlying vulnerability class has also been addressed in related areas.
- The fix has been deployed to the intended target environment and verified there.
These states are not equivalent. An additional filter might block exactly the value used in the report. A local check might secure the affected REST endpoint. Neither tells you anything about an older API version, a GraphQL resolver, a PDF export, or a background job.
A passing unit test also shows only that the tested code path produces the expected result. Whether the correct build is running in the test or production environment, and whether alternative access paths remain open, are separate questions.
Practical Example: The Role Is Correct, but the Object Is Not
Consider a multi-tenant B2B billing portal. A user from tenant A is allowed to read invoices and therefore has the INVOICE_READ permission. The invoice is downloaded through an API:
GET /api/invoices/8d87b86e-5e4e-4c98-b31a-d8e2e73062fa/pdf HTTP/1.1
Host: billing.example
Authorization: Bearer dXNlci1hLXRva2Vu
The application verifies authentication and the user’s role. It then loads the invoice using the supplied UUID:
@PreAuthorize("hasAuthority('INVOICE_READ')")
public InvoicePdf download(UUID invoiceId) {
Invoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(NotFoundException::new);
return pdfService.render(invoice);
}
The code answers one question correctly: Is this user generally authorised to read invoices?
A second question is missing: Is the user authorised to read this specific invoice?
If user A knows the UUID of an invoice belonging to tenant B, that object is retrieved as well. A random UUID makes blind enumeration more difficult, but it is not an authorisation check. Identifiers may appear in emails, exports, sharing links, logs, or responses from other endpoints. In its guidance on Broken Object Level Authorization, OWASP also points out that the identifier’s data type—sequential number, UUID, or string—does not solve the underlying problem.
The API is therefore vulnerable to Broken Object Level Authorization, or BOLA. In traditional web application contexts, the term IDOR is also commonly used. The terms are not completely identical in every taxonomy; in this case, however, they describe the same core flaw: a user-controlled object reference leads to a record without the application reliably verifying authorisation for that record.
Why a Local Patch May Not Go Far Enough
The obvious fix is to add a check for the invoice’s tenant association in the download controller. The documented request is then rejected. This is an improvement, but the application may provide other paths to the same object:
GET /api/v1/invoices/{invoiceId}
GET /api/reports/invoices/{invoiceId}.pdf
POST /graphql
{"query":"query { invoice(id: \"8d87b86e-5e4e-4c98-b31a-d8e2e73062fa\") { id } }"}
POST /api/invoices/export
{"invoiceIds":["8d87b86e-5e4e-4c98-b31a-d8e2e73062fa"]}
Perhaps only one of these paths uses the new check. The export might call a shared service that still searches globally using findById(). Or, after its first authorised generation, the PDF might be stored in a cache whose key consists only of the invoice ID.
Tenant isolation also does not end at the controller or the relational database. The same boundary must apply to caches, search indexes, object storage, queues, and background processes. A correctly filtered repository provides little protection if an asynchronous export job loses the tenant context or a global cache serves an already generated document to the wrong requester.
This does not mean that every finding warrants an architectural redesign. With inconsistent authorisation, however, identifying other uses of the same security decision is part of remediation.
Place the Fix in a Reliable Layer
A robust solution ties data access to a securely established security context. In simplified form, the query might look like this:
@PreAuthorize("hasAuthority('INVOICE_READ')")
public InvoicePdf download(UUID invoiceId) {
UUID tenantId = tenantContext.requireTenantId();
Invoice invoice = invoiceRepository
.findByIdAndTenantId(invoiceId, tenantId)
.orElseThrow(NotFoundException::new);
return pdfService.render(invoice);
}
The source of the tenantId matters. It must not be accepted without verification from a URL parameter, the JSON body, or a header such as X-Tenant-ID. If the application supports switching tenants, it must also verify that the authenticated user belongs to the selected tenant and determine which permissions the user has there.
@PreAuthorize is not limited to simple role checks either. Spring Method Security can incorporate custom authorisation logic and make object-level decisions. What matters is therefore not the annotation itself, but the quality and consistency of the rule behind it.
Depending on the architecture, additional layers of protection may include:
- Central policy or authorisation components
- Tenant-bound repository queries
- Separate namespaces for caches, object storage, and queues
- Negative integration tests involving at least two tenants
- For suitable data models, additional row-level security in the database
PostgreSQL Row-Level Security also covers database access only. Files, search indexes, and caches remain outside its scope; database roles, connection pooling, and tenant context must also work together correctly.
From Finding to Verified Remediation
For more complex findings, it helps to treat reproduction, root cause, remediation, and deployment as distinct stages:
- Reproduce the finding. The team should be able to understand the prerequisites, identities, requests, and impact. Any uncertainties are best clarified with the penetration tester before implementation begins.
- Determine the root cause. Does the flaw reside in the controller, a shared service, data access, the role model, or the loss of security context between services?
- Identify related paths. The same resources, reused methods, older API versions, and list, export, history, bulk, and download functions deserve particular attention.
- Fix the issue at the appropriate layer. A local check may be correct. Where logic is systematically reused, a central rule is often more reliable.
- Add negative tests. In addition to permitted access, explicitly test objects belonging to other users, different tenants, role changes, and unauthorised actions.
- Deploy to the target environment. The branch, build artefact, and version actually in operation must match.
- Retest the finding and its variants. Repeat the original attack path and include plausible bypasses within the agreed scope.
- Close with evidence. The ticket or risk register should record what was changed, where it was deployed, and what was subsequently verified.
This process would be excessive for a missing HTTP security header. It is justified for broken tenant isolation because multiple roles, endpoints, and storage locations may be affected.
A Test Matrix Exposes Authorisation Gaps
For regression testing, it is not enough to call individual endpoints using an administrator account. Authorisation describes the relationship between identity, role, tenant, object, and action. A small test matrix makes these relationships visible:
| Identity | Resource | Action | Expected result |
|---|---|---|---|
| User A, tenant A | Invoice A | Read | Allowed |
| User A, tenant A | Invoice B | Read | Denied |
| User A, tenant A | Invoice B | Modify | Denied |
| Admin A, tenant A | Invoice B | Export | Denied |
| Support role | Invoice B | Read | According to the explicit support policy |
Integration and regression tests can be derived from this matrix. For critical resources, read, modify, and delete access should be considered alongside download, export, search, history, and bulk functions. The OWASP Authorization Regression Testing Cheat Sheet describes this approach: authorisation rules are modelled as a verifiable matrix and tested continuously for regressions.
Such tests do not replace a full penetration test. They do, however, help ensure that an established rule is not silently lost when the application is extended.
The Highest CVSS Score Does Not Automatically Come First
A CVSS score helps describe the technical severity of a finding consistently. To determine remediation order, the team needs additional context. In addition to Base Metrics, CVSS 4.0 explicitly provides Threat and Environmental Metrics for this purpose.
Prioritisation should consider questions such as:
- Which data and business processes are affected?
- Is the vulnerability externally accessible and reliably exploitable?
- Does it affect one user, one tenant, or potentially the entire platform?
- Are compensating controls in place?
- Can the finding be combined with other vulnerabilities?
- How risky is the change shortly before a release?
A cross-tenant flaw in an invoice export may be more urgent for the business than an isolated technical score suggests. Conversely, a high-severity finding in an isolated, retired component may be less time-critical in practice. The decision should be documented—especially when a team deliberately deviates from the order in the report.
What a Retest Confirms—and What It Does Not
A retest checks whether the previously demonstrated vulnerabilities can still be reproduced in the supplied version after remediation. Depending on the engagement, this may include plausible variants and potential bypasses. It therefore provides an independent technical assessment of the remediation status.
A retest is not a complete new penetration test. New functionality, major architectural changes, or areas outside the original scope are not assessed automatically. “No longer reproducible” and “the vulnerability class has been systematically eliminated” are not always the same conclusion either. The report should therefore state clearly which version, endpoints, and variants were included in the retest.
The engagement should also define clearly what “retest passed” means: the agreed finding can no longer be reproduced in the specified version. This does not imply any conclusion about new functionality or a changed scope.
A Good Report Makes Root-Cause Remediation Easier
The penetration tester does not have to develop the final patch. A useful report should, however, provide the team with more than a vulnerability name and a CVSS score:
- Affected roles and required prerequisites
- Reproducible requests and relevant responses
- Technical and business impact
- Information about affected components or related access paths
- A realistic direction for remediation
- Clear statements about the assessment scope and testing limitations
The development team knows about reuse and operational dependencies that may not be fully visible during a penetration test. A brief discussion often avoids unnecessary iterations: the penetration tester explains the attack path and its variants, while the team shows where the authorisation rule is implemented.
I explain how to define objectives, roles, and permitted testing activities before an assessment in “How Is the Scope of a Web and API Penetration Test Defined?”.
When Can the Ticket Really Be Closed?
The technical root cause must be addressed appropriately, the fix deployed to the intended environment, and the relevant attack path retested. For systemic flaws, related paths and regression tests are part of remediation.
The number of closed findings is a project metric. Whether an attack path has actually disappeared remains a technical question. The two should not be confused.