# CBM Portal — Release Notes

## v1.6.2 — Secure Link Reliability

**Released:** May 11, 2026
**Type:** Patch release. Forward-compatible with v1.6.1. No database migration.

---

## TL;DR

If you tried an emailed PDF link from the Portal "Secure Download" page in v1.6.1
and saw **"Download failed"** even though the file was clearly there, this
release fixes that. Click an emailed link after upgrading and the file will
arrive in a fraction of a second.

We also stopped automated email-security scanners (Safe Links, Mimecast,
Proofpoint, etc.) from quietly burning your one and only download credit before
you got the chance to click. Link caps have been raised at the same time as a
belt‑and‑suspenders measure.

---

## What changed and why

### 1. "Download failed" on emailed PDFs — fixed

When the Portal wrapper opened an emailed link, the backend returned an HTTP
200 with a perfectly intact PDF, but the browser silently rejected the response.

The cause was a mismatch between the `Content-Length` header and the body bytes:

- Encrypted PDFs on disk are laid out as **nonce (12 bytes) + ciphertext +
  GCM tag (16 bytes)** — 28 bytes of crypto overhead.
- `FormFileAsset.sizeBytes` stored that **on-disk ciphertext** size.
- The download endpoint decrypts on the fly and streams **plaintext** bytes.
- So the server promised the browser 28 more bytes than it ever sent. Modern
  fetch clients (and Chrome itself in some configurations) wait for the missing
  bytes, then treat the response as truncated.

The download endpoint now omits `Content-Length` for encrypted assets so the
body is streamed chunked. Plaintext (legacy) assets still send an accurate
`Content-Length`.

> **Symptom check:** previously the backend log showed
> `Email access audit recorded action=DOWNLOAD result=SUCCESS` while the Portal
> wrapper still surfaced "Download failed". That mismatch is gone — the audit
> still logs `SUCCESS` and the user actually gets the file.

### 2. Link credits no longer eaten by automated scanners

Email links carry a download counter — once they hit the cap, the link is
permanently dead. In v1.6.1 the cap was **2 for standard forms** and **1 for
sensitive forms**.

The problem: when an email arrives in an Outlook / Mimecast / Proofpoint
mailbox, the mail-security gateway often fetches the URL **before** the user
ever sees it, to check for malware. That fetch counted as a download. Result:

- A sensitive (max-downloads = 1) link could be exhausted before the recipient
  even opened the email.
- A standard (max-downloads = 2) link could be down to its last credit before
  the first human click, and a refresh, an antivirus second-look, or even a
  React `StrictMode` double-mount could finish it off.

This release adds a small, conservative filter in
`FormAdminPdfServiceImpl.consumeEmailTokenDownload(...)`:

| Request shape | Treatment |
|---|---|
| `HEAD` request | Counter **not** incremented |
| `GET` from User-Agent matching `safelinks`, `safe-links`, `microsoft office`, `outlookforios`, `outlookforandroid`, `mimecast`, `proofpoint`, `urldefense`, `barracuda`, `forcepoint`, `symantec`, `trendmicro`, `cisco`, `sophos`, `slackbot-linkexpanding`, `slack-imgproxy`, `skypeuripreview`, `teams`, `bingpreview`, `googleimageproxy`, `facebookexternalhit` | Counter **not** incremented |
| All other `GET`s (real browser) | Counter incremented as before |

Skipped probes still pass through authn/authz the same way and still appear in
audit logs with `Skipping download counter for jti=... - request looks like an
automated link-scanner probe`. They never deliver the PDF to the scanner
because the scanner did not present a valid bearer; the wrapper page is the
only one that does.

### 3. Default link caps raised

Even with the scanner filter, we want headroom for legitimately-double-clicked
links and corporate-proxy quirks. Defaults are now:

| Tier | TTL | Max downloads (was → now) |
|---|---|---|
| `standard` (work ticket, finance, mileage, PTO, etc.) | 24 h | **2 → 5** |
| `sensitive` (new-hire packages) | 12 h | **1 → 3** |

If you have local overrides in `application.yml` or `application-stage.yml`,
those win. The stage YAML in this repository already carries the new defaults;
no environment variables changed.

### 4. CORS — browser can now read filename/correlation id

`WebConfiguration.addCorsMappings(...)` now declares
`exposedHeaders(Content-Disposition, Content-Length, Content-Type, X-Correlation-Id)`.

The Portal wrapper previously could not read the suggested PDF filename out of
`Content-Disposition` because browsers hide non-safelisted response headers
from JavaScript unless the server opts them in. The wrapper either fell back to
a guessed filename or surfaced an opaque error.

### 5. Log noise reduced

`IdObfuscationService.decode(...)` used to log
`WARN "Failed to decode token — invalid or tampered: Tag mismatch!"` every time
an emailed-link request came in, because the emailed-link JTI format is not an
obfuscated form ID. It always succeeded after the fallback path looked it up by
JTI, but operators occasionally treated the warning as a real tamper event.

It is now `DEBUG` with a clearer message:

```
Token did not decode as an obfuscated form ID (will fall back to other token formats): ...
```

Genuine tampered tokens still ultimately produce a 404 from the downstream
audit, so this is purely log-noise cleanup.

---

## Office user impact

None visible beyond "the link now works". The same single click in the email,
the same sign-in prompt if you are not already authenticated, the same PDF
delivered to your downloads folder. If you tried a v1.6.1 link and got a
"failed" page, request a fresh email and the new link will work normally.

---

## Dev / IT impact

### Config knobs (all optional)

```yaml
# application.yml or application-stage.yml — current defaults shown
cbm:
  email:
    link:
      standard:
        ttl-hours: 24
        max-downloads: 5    # was 2 in v1.6.1
      sensitive:
        ttl-hours: 12
        max-downloads: 3    # was 1 in v1.6.1
```

If you want to revert the cap (not recommended), set the values back to `2`
and `1`.

### No DB migration

- No new columns. No new tables.
- The existing `email_access_token.max_downloads` column carries whatever value
  was in effect when the token was issued, so **tokens issued before this
  upgrade** still have their original cap until they expire naturally.

### Rollback

A clean revert to v1.6.1 reintroduces the `Content-Length` over-report and
restores the old caps. The "download failed" symptom returns. Forward-fix is
strongly preferred.

### CORS

If the Portal is fronted by an additional proxy/CDN that strips response
headers, ensure it passes through `Access-Control-Expose-Headers`. The backend
now emits:

```
Access-Control-Expose-Headers: Content-Disposition, Content-Length, Content-Type, X-Correlation-Id
```

---

## Bundled fixes / hardening (full list)

- `FormAdminPdfServiceImpl.buildAssetResponse(...)` — omit `Content-Length` for
  encrypted assets to prevent body-vs-header mismatch.
- `FormAdminPdfServiceImpl.consumeEmailTokenDownload(...)` — skip counter for
  HEAD requests and known link-scanner User-Agents.
- `IdObfuscationService.decode(...)` — fallback path logs at `DEBUG`.
- `WebConfiguration.addCorsMappings(...)` — explicit `exposedHeaders` for
  `Content-Disposition`, `Content-Length`, `Content-Type`, `X-Correlation-Id`.
- `application.yml`, `application-stage.yml` — `max-downloads` raised to 5 / 3.

---

## Common questions

**Q. The token is still single-use for sensitive forms, right?**
A. No, it is 3-use for sensitive and 5-use for standard by default. The
audit log still records every successful download with the actor, IP, and
correlation id, so it is auditable. If your security review needs single-use
for sensitive again, set
`cbm.email.link.sensitive.max-downloads: 1` and accept that mail-gateway
pre-fetches can occasionally consume it.

**Q. Why not just stop counting all link-scanner traffic?**
A. We only skip the counter when the request is *obviously* automated (HEAD
method, or scanner User-Agent). Anything that looks like a real browser still
counts toward the cap, so the link is not effectively unlimited. If you want
strict single-use semantics, lower the cap; the scanner-skip stays useful even
then.

**Q. Will scanners be able to download the PDF?**
A. No. The endpoint still requires a valid bearer token from a signed-in
portal user. Scanners do not present one, so they receive a 401 before any
file bytes are streamed. The counter-skip only prevents their authn failure
from counting against the user's cap.

**Q. Where do I confirm a download went through?**
A. `email_access_audit` table — look for `action='DOWNLOAD'` and
`result='SUCCESS'` with the actor email and IP. The `correlationId` matches
the `X-Correlation-Id` response header so you can grep the application log
end-to-end.

---

## Help

If a recipient still sees "Download failed" after the upgrade:

1. Confirm the backend has been restarted on the affected environment.
2. Open browser DevTools → Network and capture the response headers for the
   `GET /api/v1/admin/forms/{formType}/{formToken}/pdf` request.
3. Capture the wrapper's Console error line.
4. Share both with the backend team along with the `X-Correlation-Id` from the
   response — that ID maps to the exact server-side trace.

