# Sample penetration testing report for a fictional app

Source: https://penetrationtestservices.com/sample-report/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

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:

1. Direct HTTP requests: we sent requests with an HTTP client, with and without credentials, and saved each request and response with a timestamp.
2. 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.
3. 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.
4. 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.
5. 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.
6. 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. |
| Email | 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.

9Findings
1Critical
3High
3Medium
2Low
7Controls that held

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

1. 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.
2. Encode note titles where they are rendered, and add a Content Security Policy (F-02).
3. Bind the login flow to a CSRF token and set `SameSite` on the session cookie (F-03).
4. Ignore the `role` field from the client on invitation requests (F-04).
5. 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.

```http
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
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.

```http
GET /v1/exports/exp_000412/download HTTP/1.1
Host: api.harbor-notes.example
Authorization: Bearer $TOKEN_MEMBER_B
```

```http
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.

```js
// 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

```http
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 "}
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{"id": "note_test_0031", "title": "EXC-TEST "}
```

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.

```jsx
// SharedNoteItem.jsx
- 
+ {note.title}
```

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

```http
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=
```

```http
HTTP/1.1 302 Found
Location: https://app.harbor-notes.example/
Set-Cookie: hn_session=; 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.

```js
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

```http
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
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.

```js
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/*/download` where 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](/methodology/) page describes how we work. To ask about a test of your own application, [contact us](/contact/). For a small, scoped test at no cost, see the [free pentest pilot](/free-pentest/).*

## 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.
