Sample penetration testing report for a fictional app
A full sample penetration testing report for a fictional SaaS app: scope, method, nine findings with requests and evidence, controls that held, and a fix plan.
Short answer
This sample penetration testing report shows what Excession Labs delivers: project details, method, safety limits, evidence labels, an executive summary, nine findings with HTTP evidence and fixes, controls that held, a remediation plan and an evidence index. The target is a fictional app called Harbor Notes.
Demonstration
This is a demonstration report for a fictional application. No real customer system was tested, and the findings below were written to show our report format and evidence standard.
Harbor Notes, its hosts, accounts, identifiers and file hashes are invented. Request and response bodies are shortened for reading.
- 01Project details
- 02Assessment method
- 03Safety constraints
- 04Evidence labels
- 05Executive summary
- 06Findings at a glance
- 07Reproduction environment
- 08Findings F-01 to F-09
- 09Controls that held
- 10Remediation plan
- 11Limitations and open questions
- 12Evidence index
Project details#
The report covers a grey-box assessment of the Harbor Notes web application and JSON API.
| Item | Value |
|---|---|
| Client | Harbor Notes (fictional) |
| Report title | Web application and API security assessment |
| Assessment type | Authorized grey-box web and API assessment with a final written report |
| Testing window | 2026-10-19 to 2026-10-23 |
| Report date and version | 2026-11-09, version 1.0 |
| Authorization | Written scope and authorization signed by the system owner before testing |
| Test accounts | Two tenants, three roles per tenant (owner, admin, member), created for this test and marked as test data |
Scope and assets#
We tested only the assets marked Yes.
| Asset | Type | In scope |
|---|---|---|
https://app.harbor-notes.example |
React single-page app, Node.js backend | Yes |
https://api.harbor-notes.example/v1/ |
JSON API used by the app | Yes |
| Client-side JavaScript bundles served by the app | Static files | Yes, read-only review |
Admin panel at /admin on the app host |
Web UI and API routes | Yes |
| Payment provider checkout pages | Third-party | No |
| Email delivery provider | Third-party | No |
| Denial-of-service and load testing | Method | No |
| Social engineering of staff | Method | No |
Features reached through the in-scope hosts: workspaces, notes, sharing links, file attachments, invitations, billing plans, a search endpoint and the admin panel.
Severity scale#
We use a four-level scale and add no CVSS vectors by default. If your program needs CVSS, say so during scoping and we agree the method in writing.
| Level | Definition |
|---|---|
| Critical | An attacker can read or change data across tenants, or take over accounts or the platform, with little effort and no special access. Fix before the next release. |
| High | An attacker can cause serious harm to users or data, but needs a signed-in account, a user action, or a chain of two weaknesses. |
| Medium | The weakness has a real effect, but the effect is limited by scope, by required conditions, or by other controls that work. |
| Low | The weakness gives small information or hardening value. It does not give direct access to data or functions. |
We set each level from the effect we proved. When a control changes our view, we change the level and say so in the finding.
Assessment method#
We tested the application as an authenticated user with known roles. We started from the client JavaScript bundle and the observed API traffic, then tested each object type and function against the roles.
We used these methods:
- Direct HTTP requests: we sent requests with an HTTP client, with and without credentials, and saved each request and response with a timestamp.
- Browser-observed tests: we checked cross-site request forgery and script injection in a real Chromium browser. A script that runs in a browser is stronger proof than a payload that appears in a response body.
- Client bundle review: we read the JavaScript bundles to list API routes, feature flags and client-side role checks that the server may not repeat.
- Differential comparison: we sent the same request as an anonymous caller, as each role in tenant A and as each role in tenant B, then compared status codes and bodies.
- Read-back from a second account: when a request changed data, we read the record back through a different session. A "200 OK" or
{"success": true}is not proof of an effect. - Controls: we ran control requests next to each exploit request: nonexistent identifiers, wrong record types, unsigned or wrongly signed cookies, another account's token, and anonymous against signed-in comparisons.
Safety constraints#
Destructive proofs used only test-owned accounts and records. Each record we created carried the marker EXC-TEST in its title, so the client can find and remove it.
| Area | Limit applied |
|---|---|
| Accounts and data | Only the six test accounts and records they own. No real user data was read. Where a finding crossed tenants, tenant B was a test tenant we owned. |
| Uploads | At most 12 files, each under 4 KB, text and SVG only. We deleted them after the test. |
| Password guessing | We guessed no passwords on any account. The reset test used one test account and at most 60 reset requests. |
| Reset and invitation emails went only to test inboxes. The bounded reset test sent 60 emails. | |
| Paid third-party APIs | None called. Billing routes were read, not executed. |
| Scripts | Reproduction scripts are read-only where possible. Run them only with authorization and with test-owned identifiers. |
Evidence labels#
Every finding carries one label. The label tells you how strongly the evidence supports the claim.
| Label | Meaning |
|---|---|
| Observed | We reproduced the effect with direct requests and confirmed it by read-back or a control. |
| Browser-observed | We showed the effect in a real Chromium browser session. |
| Inferred | We believe the path exists from code review or indirect signs, but we did not prove it. We label it separately and keep it out of confirmed impact. |
Open questions go to the section "Limitations and open questions", never into confirmed impact.
Executive summary#
We found nine issues in Harbor Notes: one Critical, three High, three Medium and two Low. One issue allows any signed-in user to download another tenant's exported workspace. We proved this with two test tenants we own.
Counts come from the findings table, as of the report date 2026-11-09. One finding (F-08) started as High and ended as Low after a control showed our first reading was wrong. We kept it in the report to show the change.
The root causes fall into three groups. Object access checks are missing on some routes (F-01, F-04). Browser protections are missing or incomplete (F-02, F-03, F-07). Abuse limits and lifecycle rules are missing (F-05, F-06). The two Low findings are hardening items.
Priority actions#
- Add a workspace ownership check to the export download route (F-01). Until it ships, disable the route or block cross-tenant reads at the API gateway.
- Encode note titles where they are rendered, and add a Content Security Policy (F-02).
- Bind the login flow to a CSRF token and set
SameSiteon the session cookie (F-03). - Ignore the
rolefield from the client on invitation requests (F-04). - Add per-account and per-address limits to password reset (F-05).
Actions 1 to 4 close the High and Critical findings. Action 5 is the first Medium item and uses a standard control.
Findings at a glance#
The table lists every finding with its severity, current status and evidence label. Status is the state at report delivery. No fixes have been verified yet.
| ID | Title | Severity | Status | Evidence label |
|---|---|---|---|---|
| F-01 | Cross-tenant export download through predictable export IDs | Critical | Open | Observed |
| F-02 | Stored XSS in shared note titles | High | Open | Browser-observed |
| F-03 | Login CSRF on the sign-in endpoint | High | Open | Browser-observed |
| F-04 | Invitation endpoint accepts a client-chosen role | High | Open | Observed |
| F-05 | No rate limit on password reset | Medium | Open | Observed |
| F-06 | Share links survive when a note becomes private | Medium | Open | Observed |
| F-07 | Attachments served inline with the uploaded content type | Medium | Open | Browser-observed |
| F-08 | Audit log readable by the member role | Low | Open, downgraded from High | Observed |
| F-09 | Search errors disclose internal index names | Low | Open | Observed |
Reproduction environment#
All requests used these values, so you can reproduce each finding with the same setup.
| Item | Value |
|---|---|
| Tenant A | Workspace ws_test_A, users owner-a, admin-a, member-a |
| Tenant B | Workspace ws_test_B, users owner-b, admin-b, member-b |
| Test data marker | Titles start with EXC-TEST |
| HTTP client | curl 8.x and a scripted client that saves each exchange with a UTC timestamp |
| Browser | Chromium, default profile, no extensions, fresh profile for each browser test |
| Authentication | Bearer token in the Authorization header for API calls. Session cookie hn_session for the web app. |
| Token placeholders | $TOKEN_OWNER_A, $TOKEN_MEMBER_B and so on. Real token values are not printed in this report. |
F-01: Cross-tenant export through predictable export IDs#
Critical Evidence label: Observed
Description#
Harbor Notes lets a workspace owner export all notes and attachments as a ZIP archive. The app creates an export job and then downloads the result from GET /v1/exports/{export_id}/download.
Export IDs are sequential integers with a prefix, for example exp_000412, then exp_000413. The download route checks that the caller has a valid token. It does not check that the caller belongs to the workspace that owns the export.
The route that lists exports for a workspace (GET /v1/workspaces/{id}/exports) does check membership. The download route does not repeat the check.
Impact#
Any signed-in user, in any tenant, can download the full export of any other tenant. The attacker needs no special role, and a free-plan account is enough. The attacker can walk the ID range to collect exports across the platform.
An export holds every note in the workspace, the member list and all attachments. Together these make up the full content of a customer account. We rate this Critical because it needs no chain, no user action and no privilege.
Verified results#
We created an export in tenant A, then fetched it with a token for a member of tenant B. Tenant B received the archive that belongs to tenant A.
| Step | Account | Result |
|---|---|---|
Create a note EXC-TEST marker-7f3a in ws_test_A, then start an export |
owner-a |
exp_000412 created, status ready |
Download exp_000412 |
member-b |
200, ZIP of 2,318 bytes |
| Open the archive | n/a | Contains the note EXC-TEST marker-7f3a from tenant A |
Download exp_000412 with no token |
anonymous | 401 |
The marker note was written by tenant A and never shared with tenant B. Its presence in the archive that member-b downloaded is the read-back proof. Evidence: ev-01-export-create.json, ev-02-export-xtenant.http, ev-03-readback.json.
We tested only IDs we created, did not walk the ID range, and opened no export we did not create.
Reproduction#
Step 1. Tenant A creates an export.
POST /v1/workspaces/ws_test_A/exports HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_OWNER_A
Content-Type: application/json
{"include_attachments": true}
HTTP/1.1 202 Accepted
Content-Type: application/json
{"id": "exp_000412", "workspace_id": "ws_test_A", "status": "ready"}
Step 2. A member of tenant B requests the same export.
GET /v1/exports/exp_000412/download HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_MEMBER_B
HTTP/1.1 200 OK
Content-Type: application/zip
Content-Disposition: attachment; filename="ws_test_A-export.zip"
Content-Length: 2318
The filename value names tenant A's workspace, so the response itself shows the cross-tenant effect.
Controls#
| Control request | Result | What it shows |
|---|---|---|
Download exp_999999 (nonexistent) as member-b |
404 | The route does look up the record |
Download exp_000412 with no token |
401 | Authentication works |
GET /v1/workspaces/ws_test_A/exports as member-b |
403 | The list route checks membership |
| Download with a token whose signature we changed | 401 | Token signatures are verified |
Download exp_000412 as member-a |
200 | Same-tenant access works, as designed |
The control with the list route shows that a membership check exists in the codebase. The download route is missing it.
Remediation#
Check workspace membership in the download handler, after the lookup and before any file is returned. Return 404 for exports the caller may not read, so the response does not confirm that an ID exists.
// GET /v1/exports/:exportId/download
const exp = await db.exports.findById(req.params.exportId);
if (!exp) return res.status(404).json({ error: "not_found" });
const member = await db.memberships.find({
workspaceId: exp.workspaceId,
userId: req.user.id,
});
if (!member || !can(member.role, "workspace:export")) {
return res.status(404).json({ error: "not_found" });
}
return streamExport(exp, res);
Also replace sequential IDs with random 128-bit identifiers. Random IDs make enumeration harder but do not replace the access check. Add an automated test that requests each object route with a token from another tenant and expects 404. Review other routes that take an object ID in the path for the same missing check.
F-02: Stored XSS in shared note titles#
High Evidence label: Browser-observed
Description#
When a user shares a note, the "Shared with me" list shows the note title to each recipient. The React app renders the title with dangerouslySetInnerHTML in the list item component. The API stores the title as typed and does not encode it.
A title that contains HTML runs in the recipient's browser when the recipient opens the "Shared with me" page.
Impact#
An attacker with any account can run script in the app origin as the recipient. The script can read data that the recipient's session can read, and it can call the API as the recipient. If the recipient is a workspace owner, the script can create invitations or exports in their name.
The attacker needs one user action: the recipient must open the list. The recipient does not need to open the note. We rate this High and not Critical because it needs a user action and a sharing relationship.
Verified results#
We shared a note from member-a to owner-b (both test accounts). The title contained a harmless marker script. When we opened "Shared with me" as owner-b in Chromium, the script ran in the origin https://app.harbor-notes.example and wrote a marker into the page.
| Check | Result |
|---|---|
Script ran in Chromium as owner-b |
Yes, marker text EXC-TEST xss-fired added to the page |
| Script origin | https://app.harbor-notes.example |
| Session cookie readable by script | No, hn_session is HttpOnly |
API call made by the script as owner-b |
Yes, GET /v1/me returned the profile of owner-b |
| Same title shown in the note editor | Encoded as text, no script ran |
The editor encodes the title and the list does not, so the flaw is in one component. Evidence: ev-04-xss-browser.png, ev-05-xss-console.json.
The payload used only the marker and the /v1/me read. We did not send data to any outside host.
Reproduction#
PATCH /v1/notes/note_test_0031 HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_MEMBER_A
Content-Type: application/json
{"title": "EXC-TEST <img src=x onerror=\"document.body.dataset.exc='fired'\">"}
HTTP/1.1 200 OK
Content-Type: application/json
{"id": "note_test_0031", "title": "EXC-TEST <img src=x onerror=\"document.body.dataset.exc='fired'\">"}
Then share the note with owner-b and open /shared as owner-b in Chromium. The onerror handler sets document.body.dataset.exc, and the test reads it with the browser developer tools.
Controls#
| Control | Result |
|---|---|
| Title with plain text only | Rendered as text |
| The same title in the note editor | Encoded, no script |
Content-Security-Policy response header |
Not present on the app host |
HttpOnly flag on hn_session |
Set, so the script could not read the cookie |
| Title rendered on the public share page | Encoded, no script (the public page uses a different component) |
Remediation#
Remove dangerouslySetInnerHTML from the list component and render the title as a React text node.
// SharedNoteItem.jsx
- <span dangerouslySetInnerHTML={ { __html: note.title } } />
+ <span>{note.title}</span>
Add a Content Security Policy that blocks inline script, for example script-src 'self'; object-src 'none'; base-uri 'none'. A policy limits the effect of the next injection bug. Add a lint rule that fails the build on dangerouslySetInnerHTML without an approved sanitizer. Search the other list components for the same pattern.
F-03: Login CSRF on the sign-in endpoint#
High Evidence label: Browser-observed
Description#
POST /v1/auth/login accepts a form-encoded body and sets the session cookie. It has no CSRF token and no Origin check. The hn_session cookie is set with SameSite=None.
A page on another site can submit a login form that signs the victim's browser in to the attacker's account.
Impact#
The victim keeps using Harbor Notes without noticing that the session belongs to the attacker's account. Notes and attachments that the victim writes then go to the attacker. This is a data-loss path for users who do not notice the change of account.
We rate this High because the effect is a silent data transfer and users may paste sensitive text into notes. We did not prove a path to account takeover.
Verified results#
We hosted a test page on a separate origin we control. The page auto-submitted a login form with the credentials of member-b. We opened the page in a Chromium profile that held no session.
| Check | Result |
|---|---|
| Chromium profile after the test page loaded | Signed in as member-b on app.harbor-notes.example |
Request sent with Origin: https://csrf-test.example |
302, Set-Cookie: hn_session=...; SameSite=None; Secure; HttpOnly |
JSON login with Content-Type: application/json |
415, so only the form route is exposed |
Evidence: ev-06-csrf-login.html, ev-07-csrf-browser.png.
Reproduction#
POST /v1/auth/login HTTP/1.1
Host: api.harbor-notes.example
Origin: https://csrf-test.example
Content-Type: application/x-www-form-urlencoded
email=member-b%40test.harbor-notes.example&password=<test-owned password>
HTTP/1.1 302 Found
Location: https://app.harbor-notes.example/
Set-Cookie: hn_session=<redacted>; Path=/; Secure; HttpOnly; SameSite=None
Controls#
| Control | Result |
|---|---|
| Login with a wrong password | 401, no cookie |
| Same request with JSON content type | 415, so a cross-site JSON form post is not possible |
CSRF on POST /v1/notes (state change after login) |
Blocked, because that route needs the bearer token from the app |
| Cookie flags | Secure and HttpOnly set |
The CSRF control on POST /v1/notes shows that state-changing API routes are protected. Only the sign-in route is open.
Remediation#
Set SameSite=Lax on the session cookie unless a cross-site flow needs None. Check the Origin header on the login route and reject any origin that is not on the allow list. Add a double-submit CSRF token to the login form.
const ALLOWED = new Set(["https://app.harbor-notes.example"]);
app.post("/v1/auth/login", (req, res, next) => {
const origin = req.get("Origin");
if (!origin || !ALLOWED.has(origin)) {
return res.status(403).json({ error: "bad_origin" });
}
next();
});
After login, show the signed-in account name in the app header, so users can see which account they use.
F-04: Invitation endpoint accepts a client-chosen role#
High Evidence label: Observed
A workspace member can invite a user as admin. POST /v1/workspaces/{id}/invitations reads the role field from the request body and does not compare it to the caller's role. The member-a account invited a second test account as admin. After acceptance, GET /v1/workspaces/ws_test_A/members as that account showed role admin, and the account could change billing settings. Evidence: ev-08-invite-role.http.
Controls: the same request with role: "owner" returned 400, a request for a nonexistent workspace returned 404, and the web UI hides the role selector from members. The UI check is client-side only. Fix: derive the allowed roles from the caller's role on the server, and reject any role that is higher than the caller's own role.
F-05: No rate limit on password reset#
Medium Evidence label: Observed
Description#
POST /v1/auth/password-reset sends a reset email to the address in the request. The route has no limit by address, by IP or by account. It returns the same response for known and unknown addresses.
Impact#
An attacker can send many reset emails to one user. This floods the user's inbox and costs Harbor Notes email sending fees. It can also hide a real security email among many others. The route does not reveal whether an account exists.
We did not find a way to guess or bypass the reset token. We rate this Medium because the effect is nuisance and cost, and it gives no access.
Verified results#
We ran a bounded test against one test account (member-a) with a test inbox. We sent 60 requests in 90 seconds from one IP address and stopped there.
| Measure | Result |
|---|---|
| Requests sent | 60 |
| Responses with 202 | 60 |
| Responses with 429 or any block | 0 |
| Reset emails received in the test inbox | 60 |
| Cost of the test | 60 emails to a test inbox, no paid API called |
All 60 emails arrived, so the server applied no limit in that window. Evidence: ev-09-reset-burst.json, ev-10-reset-inbox.json.
We chose 60 because it is large enough to show that no short-window limit exists and small enough to stay within the safety limit in this report. We did not test higher counts.
Reproduction#
POST /v1/auth/password-reset HTTP/1.1
Host: api.harbor-notes.example
Content-Type: application/json
{"email": "member-a@test.harbor-notes.example"}
HTTP/1.1 202 Accepted
Content-Type: application/json
{"status": "if_account_exists_email_sent"}
Repeat the request 60 times with a test-owned address and count the 202 responses.
Controls#
| Control | Result |
|---|---|
| Request for an unknown address | Same 202 body, no account enumeration |
| Use of an old reset token | 400, tokens are single-use |
| Reset token length | 32 random bytes, no pattern seen in 5 samples |
| Reuse of a token after a password change | 400 |
Remediation#
Limit requests by address and by IP. A common starting point is 3 requests per address per hour and 20 per IP per hour. Return the same 202 response when a limit applies, so the limit does not leak account state.
const resetLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
keyGenerator: (req) => req.body.email?.toLowerCase() ?? req.ip,
handler: (req, res) =>
res.status(202).json({ status: "if_account_exists_email_sent" }),
});
app.post("/v1/auth/password-reset", resetLimiter, resetHandler);
Add an alert for a high rate of reset requests, and consider a CAPTCHA after repeated requests.
F-06: Share links survive when a note becomes private#
Medium Evidence label: Observed
When an owner sets a note to private, the public share link https://app.harbor-notes.example/s/{token} still returns the note. We created a link for a test note, switched the note to private in the UI (the UI showed "Private"), and requested the link as an anonymous caller. The response was 200 with the note body. The link stayed valid after we also removed the sharing record in the UI. A fresh link for the same note had a different token, so the old one was never revoked. Evidence: ev-11-share-after-private.http.
Controls: a link with one character changed returned 404, tokens were 22 random characters, and deleting the note made the link return 404. Fix: when a note changes to private, delete or revoke its share tokens in the same database transaction. Add an optional expiry date for share links, and show the owner a list of active links.
F-07: Attachments served inline with the uploaded content type#
Medium Evidence label: Browser-observed
The download route GET /v1/files/{id} returns the file with the content type chosen at upload and with Content-Disposition: inline. It sets no X-Content-Type-Options: nosniff header. An SVG file that contained a marker script ran in Chromium when we opened the file URL directly.
The script ran on the API origin api.harbor-notes.example, not on the app origin. The session cookie is not sent to the API host, and the API needs a bearer token that lives in app memory. So the script could not read the token. This control is why we rate the finding Medium and not High. If the two hosts ever share a registrable cookie scope or a token store, the finding becomes High.
The upload test used 3 files under 4 KB, which we deleted. Evidence: ev-12-svg-inline.png. Fix: serve user files from a separate sandbox domain, send Content-Disposition: attachment for any type other than a short allow list of images, set X-Content-Type-Options: nosniff, and add Content-Security-Policy: sandbox on file responses.
F-08: Audit log readable by the member role#
Low Evidence label: Observed. Downgraded from High.
Description#
GET /v1/admin/audit-log returned 200 for member-a. The route sits under the admin prefix, so we first recorded it as High: an admin function open to the lowest role.
What changed our view#
We ran the controls after the first observation. They showed that our first reading was wrong.
| Check | Result | Effect on severity |
|---|---|---|
| Entries in the response, tenant A caller | Only ws_test_A entries |
No cross-tenant exposure |
Compare with the "Activity" page in the app, as member-a |
Same events | The UI already shows these events to members |
POST /v1/admin/users/{id}/disable as member-a |
403 | Admin write functions are protected |
GET /v1/admin/users as member-a |
403 | Admin read functions are protected |
| Extra fields in the audit log compared with the Activity page | ip_address of each actor |
The only real difference |
The route is a read-only view of events that members can already see. The only extra data is the IP address of other members in the same workspace. We changed the level from High to Low. Evidence: ev-13-auditlog-member.http, ev-14-auditlog-controls.http.
Remediation#
Remove ip_address from the member view, or restrict the route to admin and owner roles. Name the route outside the /admin prefix if it is meant for all members, so route names match their access rules.
F-09: Search errors disclose internal index names#
Low Evidence label: Observed
GET /v1/search?q= with a malformed filter value returns a 500 response whose body includes the internal index name (notes_v7_tenant_shard_3) and the search library version. The text helps an attacker plan later tests. It gives no access to data. We tested with three malformed values on test data, and a valid query returned normal results with tenant scoping intact. A cross-tenant query with a workspace_id filter for tenant B returned only tenant A results. Evidence: ev-15-search-error.http. Fix: catch search errors, log the detail on the server, and return a generic error with a request ID.
Controls that held#
These protections worked under test. Fixes must not remove them.
| Control | What we tried | Result |
|---|---|---|
| Workspace list routes check membership | member-b requested lists of tenant A notes, members and exports |
403 on each route |
| Note read, edit and delete by ID | member-b used note IDs from tenant A |
404, no data returned |
| Token signature checks | Tokens with a changed signature or algorithm none |
401 |
| Session cookie flags | Inspected hn_session |
Secure and HttpOnly set |
| Admin write routes | member-a called user disable and plan change routes |
403 |
| Search tenant scoping | Query with workspace_id of tenant B from tenant A |
Only tenant A results |
| Reset tokens | Reuse, and use after a password change | 400, single-use |
We list these because the fixes for F-01 and F-04 touch the same authorization code. A regression test for each control keeps the fix from weakening it.
Remediation plan#
The plan orders actions by the risk they remove. Owners are placeholders. Effort is S (under one day of work), M (a few days) or L (more than a week).
| Action | Findings | Owner | Effort |
|---|---|---|---|
| Add a workspace membership check to the export download route and switch to random IDs | F-01 | Backend team | S |
| Add cross-tenant tests for every route that takes an object ID | F-01, F-04 | Backend team | M |
| Render note titles as text and add a Content Security Policy | F-02 | Frontend team | M |
Check Origin on login, set SameSite=Lax, add a CSRF token |
F-03 | Backend team | S |
| Validate invitation roles against the caller's role | F-04 | Backend team | S |
| Add rate limits and alerts to password reset | F-05 | Backend team | S |
| Revoke share tokens when a note becomes private, add expiry | F-06 | Backend team | M |
Move user files to a sandbox domain, add nosniff and attachment headers |
F-07 | Platform team | L |
| Restrict the audit log route and remove actor IP from the member view | F-08 | Backend team | S |
| Return generic search errors with a request ID | F-09 | Backend team | S |
After the fixes ship, ask for a retest of F-01 to F-04. A retest uses the same requests and the same controls.
Limitations and open questions#
The points here are limits of this assessment, not confirmed findings.
- Time-boxed: Testing ran for five days against a fixed scope. A lack of findings in an area does not show that the area has no issues.
- Grey-box accounts: We tested with the roles we were given. Roles that the client adds later were not tested.
- Unproven chain (inferred): F-02 and F-04 might combine: script in an owner's session could send an invitation with a chosen role. We did not run this chain, so we do not count it as impact.
- Open question on export history. We do not know whether other tenants' exports were downloaded before this test. The client should check access logs for
GET /v1/exports/*/downloadwhere the caller's workspace differs from the export's workspace. - Open question on F-07 and cookie scope. The client should confirm that the app and API hosts will never share a cookie scope.
- Not tested: Third-party checkout pages, the email provider, load behavior, and the mobile apps.
Evidence index#
Each item is a saved request, response, screenshot or script output with a UTC timestamp. The hashes below are placeholders for this demonstration. In a real report, the SHA-256 manifest lets you check that a file has not changed.
| File | Finding | SHA-256 (placeholder) |
|---|---|---|
ev-01-export-create.json |
F-01 | 3b1f9c07a2de45e18c60b7f4d29a51ce8e3f0a6b7d4c92e15f8a30bd6c7e2194 |
ev-02-export-xtenant.http |
F-01 | a94c5e2d7b01f386c4d9e0a7152b8f63de4a19c07b5e3f82a6d1c940e7b3582f |
ev-03-readback.json |
F-01 | 7e20d4b9c15a83f6e02b7d94a1c5f3086b9e4d27a0c18f5e3b6d92a4c07e1f58 |
ev-04-xss-browser.png |
F-02 | c58a1e93f2b04d76a9e1c3058d7f2b64e0a93c1d58b7f4e26a0d9c315b8e7a42 |
ev-05-xss-console.json |
F-02 | 1d93b7e5a08c42f6d1b9e3705a4c8f26b0e7d15a93c4f8e2061b7d5a39c0e4f8 |
ev-06-csrf-login.html |
F-03 | e4072b9d1c85a3f6b0d7e2941a5c8f3d60b9e72a14c5d8f3e0a6b19c7d2f4e85 |
ev-07-csrf-browser.png |
F-03 | 5f1a8c3e90b7d24a6e1c05f9b83d7a42c0e6f19b5a3d8e7c24f0b61a9d3c5e78 |
ev-08-invite-role.http |
F-04 | 92c6e0a4b81d37f5a9c2e60d4b7f18a3e5c09d26b4f7a81e3c0d59b2a6f4e170 |
ev-09-reset-burst.json |
F-05 | b30d7a15e9c4f862a0d3b7e51c9f4a68d2e0b73c5a1f8e94d6b0a27c3e5f1980 |
ev-10-reset-inbox.json |
F-05 | 46e9b2c0d71a58f3e4b6a09d2c7f1583b0e8a4d69c2f5e170a3b8d4c96e1f2a7 |
ev-11-share-after-private.http |
F-06 | d85a3f0c6e1b94a27d5c0e8f3b6a1942c7e0d5b83a9f4e2160c7b5d1a8e3f904 |
ev-12-svg-inline.png |
F-07 | 0a7c4e9b2d6f1583e0a9c7b4d21f6e85a3c0b97d4e2f1a68c5b0d3e7f9a41c26 |
ev-13-auditlog-member.http |
F-08 | f2b81d6a4c0e9573b1d8f2a6e04c7b93d5a0e1f84c6b2a9d73e5f0c1b8a4d627 |
ev-14-auditlog-controls.http |
F-08 | 6c0e3a9d5b71f248e8a0c4d6b2f93e17a5d0c8b4e6f1a39d2c7b05e4f8a1d3c9 |
ev-15-search-error.http |
F-09 | 89d4b1f7e0a3c652d9b8e4a07f1c5d3e2b60a9c8d4f7e15a3b0c6d92e8f4a1b5 |
Appendix: reproduction scripts#
The proof-of-concept scripts ship with the report as files, one per finding. They are read-only where possible and take identifiers as arguments, for example repro/f01_readback.sh ws_test_A exp_000412. Run them only with authorization from the system owner and with test-owned identifiers.
This page is a demonstration. The methodology page describes how we work. To ask about a test of your own application, contact us. For a small, scoped test at no cost, see the free pentest pilot.
Questions
Is this a real customer report?
No. Harbor Notes is a fictional application and no real system was tested. We wrote the findings to show our report format and evidence standard. The hosts use the reserved .example domain, and the hashes in the evidence index are placeholders.
What format do you deliver?
We deliver the report in HTML, PDF and Markdown, plus proof-of-concept scripts. The scripts are read-only where possible. You must run them only with authorization and with test-owned identifiers.
Do you use CVSS?
Not by default. We use a four-level scale (Critical, High, Medium, Low) with a written definition for each level. If your program needs CVSS vectors, tell us during scoping and we agree the method in writing.
Does every finding have proof?
Each finding carries an evidence label. A finding is confirmed only when we show a real effect, for example a change that a second account reads back. Inferred paths are labeled separately, and open questions stay out of confirmed impact.
Need a pentest you can act on?
Tell us what you need tested and why. We reply to agree scope, and testing starts only after written authorization.