diff --git a/docs/csp-apache.conf.md b/docs/csp-apache.conf.md index c7d578e7..b92563d0 100644 --- a/docs/csp-apache.conf.md +++ b/docs/csp-apache.conf.md @@ -6,7 +6,9 @@ Adjust `report-uri` if your API base path or host differs (UAT example below; PR See also: -- [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — latest PROD + UAT report review and add/remove summary +- [permissions-policy-apache.conf.md](./permissions-policy-apache.conf.md) — Permissions-Policy reporting via the same `/api/csp-report` URL +- [csp-report-review-2026-09-22.md](./csp-report-review-2026-09-22.md) — latest PROD report review (`All_CSP.txt`) +- [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — earlier PROD + UAT report review and add/remove summary - [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — earlier PROD report analysis that drove `frame-src` / `media-src` updates ## Enforcing diff --git a/docs/csp-report-review-2026-08-03.md b/docs/csp-report-review-2026-08-03.md index 5200455c..db91127c 100644 --- a/docs/csp-report-review-2026-08-03.md +++ b/docs/csp-report-review-2026-08-03.md @@ -13,6 +13,7 @@ Related app docs: - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy - [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — later PROD + UAT review (add/remove summary) +- [csp-report-review-2026-09-22.md](./csp-report-review-2026-09-22.md) — later PROD review (`All_CSP.txt`) --- diff --git a/docs/csp-report-review-2026-08-14.md b/docs/csp-report-review-2026-08-14.md index 34cad47f..6c0e17d7 100644 --- a/docs/csp-report-review-2026-08-14.md +++ b/docs/csp-report-review-2026-08-14.md @@ -14,6 +14,7 @@ Related app docs: - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy - [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — earlier PROD review +- [csp-report-review-2026-09-22.md](./csp-report-review-2026-09-22.md) — later PROD review (`All_CSP.txt`) --- diff --git a/docs/csp-report-review-2026-09-22.md b/docs/csp-report-review-2026-09-22.md new file mode 100644 index 00000000..837d9c54 --- /dev/null +++ b/docs/csp-report-review-2026-09-22.md @@ -0,0 +1,197 @@ +# CSP Report Review (PROD, 2026-09-22) + +Review of Content-Security-Policy-Report-Only violations from PNSPS PROD +(`https://pnsps.gld.gov.hk`). + +Source logs (external): + +- `All_CSP.txt` — 196 reports, 2026-08-27 to 2026-09-17 + +Related app docs: + +- [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy +- [csp-report-review-2026-08-14.md](./csp-report-review-2026-08-14.md) — earlier PROD + UAT review +- [csp-report-review-2026-08-03.md](./csp-report-review-2026-08-03.md) — earlier PROD review + +--- + +## Verdict + +**No app-side CSP concern in this dump.** Almost every line is a browser +extension, not PNSPS code. `/proof/reply/{id}` is just a busy authenticated +page, so the same extension noise shows up there a lot. + +CSP is still **Report-Only** (`disposition: report`) — the browser logged these +hits and **did not block the page**. + +The 2026-08-14 app-owned issue (proof PDF preview `frame-src`) is **gone** from +this dump. Live PROD now includes `frame-src 'self' data: blob:;`. + +--- + +## Add / remove summary + +**PROD — add nothing required. Remove nothing.** + +Keep `script-src 'self'`. Do not add `'unsafe-inline'`, `'unsafe-eval'`, or +`'wasm-unsafe-eval'`. + +**Optional (low priority, already in [csp-apache.conf.md](./csp-apache.conf.md)):** + +```apache +media-src 'self' blob: data:; +``` + +Live PROD is still `media-src 'self' blob:` (no `data:`). This dump has only +2 matching hits in three weeks. + +**Do not add:** `'wasm-unsafe-eval'`, Perplexity CDN, Youdao, NetEase CDN, +Google Fonts. + +--- + +## Confirmed current PROD policy (Report-Only) + +This is the live header that generated every report in `All_CSP.txt`. It matches +`original-policy` in all 196 violations. + +```apache +Header always set Content-Security-Policy-Report-Only "default-src 'self'; \ + base-uri 'self'; \ + object-src 'none'; \ + frame-ancestors 'none'; \ + frame-src 'self' data: blob:; \ + form-action 'self'; \ + script-src 'self'; \ + style-src 'self' 'unsafe-inline'; \ + style-src-elem 'self' 'unsafe-inline'; \ + img-src 'self' data:; \ + media-src 'self' blob:; \ + font-src 'self' data:; \ + connect-src 'self'; \ + upgrade-insecure-requests; \ + report-uri https://pnsps.gld.gov.hk/api/csp-report" +``` + +Changes vs the 2026-08-14 live header: + +- `frame-src 'self' data: blob:;` is now present. +- `img-src` no longer allowlists `https://www.w3.org` / `https://w3.org` + (badge is hosted locally). + +`style-src 'unsafe-inline'` is expected for MUI and is the only real policy +weakness. These reports do not show a new XSS issue. + +--- + +## Summary of findings + +| Count | Directive | Blocked | Verdict | +|---:|---|---|---| +| **192** | `script-src` | `wasm-eval` | Noise — Chrome extension | +| 2 | `media-src` | `data` | Optional leftover — `data:` not in live `media-src` | +| 1 | `font-src` | `frontend-cdn.perplexity.ai` … `FKGroteskNeue.woff2` | Noise — Perplexity sidebar | +| 1 | `img-src` | `ydlunacommon-cdn.nosdn.127.net` … `.svg` | Noise — Youdao / NetEase plugin | + +No `frame-src` reports. No app `eval`. No Google Fonts. + +--- + +## Why `wasm-eval` volume is high + +192 of 196 reports (98%) are `script-src` / `wasm-eval`. + +| Count | `source-file` | Notes | +|---:|---|---| +| 103 | `chrome-extension` | Every one with a location is **line 23, column 39316** — the same injected script | +| 89 | (none) | Chrome often sends a second report for the same violation without `source-file` | + +About 46 timestamps have 2+ reports in the same second (paired duplicates). +That is why the log looks twice as large as the number of real events. + +Password managers, translators, Grammarly-style tools, and similar extensions +compile WebAssembly into the page. `script-src 'self'` (without +`'wasm-unsafe-eval'`) reports that. Do **not** loosen `script-src` to silence +it. + +If quieter logs are needed, filter `blocked-uri=wasm-eval` plus +`source-file=chrome-extension` (and the paired empty-source twin). + +--- + +## Why many `/proof/reply` reports + +| Count | Page group | +|---:|---| +| 90 | `/login` | +| **38** | `/proof/reply/{id}` | +| 23 | `/publicNotice/{id}` | +| 15 | `/publicNotice/apply` | +| 10 | `/publicNotice` | +| 10 | `/proof/search` | +| 4 | `/paymentPage/*` | +| 3 | `/user/changePassword` | +| 2 | `/registerFromOrganization` | +| 1 | `/verify/*` | + +The 38 Reply Proof hits span **12 proof IDs** (`29518`, `29948`, `30112`, +`30114`, `30134`, `30166`, `30190`, `30266`, `30306`, `30326`, `30484`, +`30958`). They are the same `wasm-eval` + Chrome extension pattern as login, +apply, and notice detail. + +Reply Proof is a long, authenticated screen. Anyone with a WASM extension +triggers a report on every visit. **No Reply Proof code change is required.** + +--- + +## Not a PNSPS SPA problem — do not allowlist + +| Count | Page | Directive | Blocked | Why ignore | +|---:|---|---|---|---| +| 192 | many (login 90, reply 38, …) | `script-src` | `wasm-eval` | `chrome-extension` (line 23, col 39316) or paired empty source | +| 1 | `/login` | `font-src` | Perplexity `FKGroteskNeue.woff2` | Sidebar / extension | +| 1 | `/login` | `img-src` | NetEase `ydlunacommon-cdn.nosdn.127.net` | Youdao-style translator | +| 2 | `/publicNotice/apply`, `/proof/reply/30166` | `media-src` | `data` | Optional `data:` gap only; 2 hits in three weeks | + +`status-code: 404` on 166 reports is typical SPA client-route noise, not a +broken document. + +--- + +## Optional: `media-src` and `data:` + +Live policy: `media-src 'self' blob:;` + +Captcha audio already uses `blob:` (`CustomFormWizard.js` and similar). Adding +`data:` is low risk and already documented: + +```apache +media-src 'self' blob: data:; +``` + +Not required for this dump. + +--- + +## Recommended actions + +1. **No Apache change required** for these reports. `frame-src` deploy from + 2026-08-14 looks effective (zero `frame-src` hits here). +2. Keep PROD at `script-src 'self'`. Do not add `'unsafe-eval'` or + `'wasm-unsafe-eval'`. +3. Optional: add `data:` to `media-src` to match + [csp-apache.conf.md](./csp-apache.conf.md). +4. Keep Report-Only until remaining volume is understood as extension noise; + then promote the same policy to enforcing. +5. Optional log hygiene: drop or sample `wasm-eval` + `chrome-extension` so + real app regressions stay visible. + +No application code change is required. + +--- + +## Backend reference + +- Report endpoint: `POST /csp-report` (context path → `/api/csp-report`) +- Controller: `CspReportController` in PNSPS-backend +- Frontend/Apache CSP above is what browsers apply to the SPA document diff --git a/docs/permissions-policy-apache.conf.md b/docs/permissions-policy-apache.conf.md new file mode 100644 index 00000000..9fdb4a65 --- /dev/null +++ b/docs/permissions-policy-apache.conf.md @@ -0,0 +1,152 @@ +# Permissions-Policy reporting (Apache) + +The live `Permissions-Policy` header denies features with an empty allowlist +(`feature=()` — grant to nobody). That header does **not** send reports by +itself. + +Unlike CSP, Permissions-Policy has **no `report-uri`**. Reporting uses the +Reporting API: a named endpoint in `Reporting-Endpoints`, plus a per-feature +`report-to` parameter. + +Reuse the existing CSP collector: + +- UAT: `https://pnspsuat.gld.gov.hk/api/csp-report` +- PROD: `https://pnsps.gld.gov.hk/api/csp-report` + +Backend: `CspReportController` (`POST /csp-report`) already logs the raw JSON +and `Content-Type` and returns `204`. No backend change is required. + +See also: [csp-apache.conf.md](./csp-apache.conf.md) + +## Why not `report-uri` + +This CSP-style trailer does **not** produce Permissions-Policy reports: + +```apache +# Wrong — browsers ignore report-uri on Permissions-Policy +Header always set Permissions-Policy "camera=(), geolocation=(); report-uri https://pnspsuat.gld.gov.hk/api/csp-report" +``` + +`report-to` is a **parameter of each feature**, not a global trailing directive. + +## Does every feature need `report-to`? + +Yes, **if you want a report for that feature**. + +- `feature=()` — still blocked, but silent (no report) +- `feature=();report-to=csp-endpoint` — blocked **and** reported + +There is no `all=()` reporter and no header-level `report-to` that applies to +every feature. Add `;report-to=csp-endpoint` only on features you want in the +log. Leave the rest as `()` if you do not need those reports. + +```apache +# camera is reported; geolocation is still blocked, but not reported +Header always set Permissions-Policy "camera=();report-to=csp-endpoint, geolocation=()" +``` + +## Enforcing + reporting + +Keep the current deny-all policy. Add `Reporting-Endpoints`, then append +`;report-to=csp-endpoint` on each feature you want to monitor. + +Copy the feature list from the live header; only the `report-to` parameter is +new. Example (UAT): + +```apache +Header always set Reporting-Endpoints "csp-endpoint=\"https://pnspsuat.gld.gov.hk/api/csp-report\"" + +Header always set Permissions-Policy "\ + accelerometer=();report-to=csp-endpoint, \ + autoplay=();report-to=csp-endpoint, \ + camera=();report-to=csp-endpoint, \ + display-capture=();report-to=csp-endpoint, \ + encrypted-media=();report-to=csp-endpoint, \ + fullscreen=();report-to=csp-endpoint, \ + geolocation=();report-to=csp-endpoint, \ + gyroscope=();report-to=csp-endpoint, \ + magnetometer=();report-to=csp-endpoint, \ + microphone=();report-to=csp-endpoint, \ + midi=();report-to=csp-endpoint, \ + payment=();report-to=csp-endpoint, \ + picture-in-picture=();report-to=csp-endpoint, \ + publickey-credentials-get=();report-to=csp-endpoint, \ + screen-wake-lock=();report-to=csp-endpoint, \ + usb=();report-to=csp-endpoint, \ + web-share=();report-to=csp-endpoint, \ + xr-spatial-tracking=();report-to=csp-endpoint" +``` + +PROD: same headers, with + +`https://pnsps.gld.gov.hk/api/csp-report` + +Because deny-all is already enforced, put `report-to` on **`Permissions-Policy`** +(not only Report-Only). Those reports have `disposition: "enforce"`. + +## Report-Only (observe without blocking) + +Use `Permissions-Policy-Report-Only` only when testing a restriction that is +**not** already denied by the enforcing header. Reports have +`disposition: "report"`. + +```apache +Header always set Reporting-Endpoints "csp-endpoint=\"https://pnspsuat.gld.gov.hk/api/csp-report\"" +Header always set Permissions-Policy-Report-Only "geolocation=();report-to=csp-endpoint" +``` + +Report-Only cannot re-enable a feature already denied by `Permissions-Policy`. + +## Same URL, different payload + +Keep CSP on `report-uri` as it is. The collector URL is shared; the body is not. + +| Source | `Content-Type` | Body | +| --- | --- | --- | +| CSP `report-uri` | `application/csp-report` | `{ "csp-report": { … } }` | +| Permissions-Policy | `application/reports+json` | JSON **array**, `type` = `permissions-policy-violation` | + +Example Permissions-Policy report: + +```json +[{ + "type": "permissions-policy-violation", + "url": "https://pnspsuat.gld.gov.hk/", + "body": { + "disposition": "enforce", + "featureId": "geolocation", + "message": "Permissions policy violation: geolocation access has been blocked because of a permissions policy applied to the current document." + } +}] +``` + +Chrome often serializes the feature as `policyId` instead of `featureId`. Filter +logs on `permissions-policy-violation` vs `csp-report` so the two streams stay +distinct. + +Optional: also point CSP at the same named endpoint (CSP `report-uri` remains +for older browsers): + +```apache +Header always set Content-Security-Policy-Report-Only "…; report-uri https://pnspsuat.gld.gov.hk/api/csp-report; report-to csp-endpoint" +``` + +## Follow-up checklist + +- [ ] Confirm the live Apache `Permissions-Policy` feature list (deny-all `()`). +- [ ] Add `Reporting-Endpoints` → existing `/api/csp-report` (UAT vs PROD host). +- [ ] Add `;report-to=csp-endpoint` only on features you want in the log. +- [ ] Deploy to UAT first; trigger a blocked API (e.g. `navigator.geolocation`) in Chrome/Edge. +- [ ] Confirm a `permissions-policy-violation` line in the backend log (may be batched, a few seconds later). +- [ ] Repeat on PROD with the PROD report URL. + +## Notes + +- Reporting is Chromium-only (Chrome / Edge). Safari and Firefox still enforce + `()` and usually send nothing. +- Reports are batched and may arrive a few seconds after the violation, not on + the same page request. +- Same-origin `/api/csp-report` needs no extra CORS setup. The endpoint is + already unauthenticated and CSRF is disabled. +- Do not allowlist a feature in `Permissions-Policy` just to silence a report. + Only grant a feature if the application itself needs it. diff --git a/src/assets/images/wcag2AA.png b/src/assets/images/wcag2AA.png index b706d1f5..5d1ead72 100644 Binary files a/src/assets/images/wcag2AA.png and b/src/assets/images/wcag2AA.png differ diff --git a/src/components/cards/AuthFooter.js b/src/components/cards/AuthFooter.js index 398e99fa..e4fbd89b 100644 --- a/src/components/cards/AuthFooter.js +++ b/src/components/cards/AuthFooter.js @@ -58,7 +58,7 @@ const AuthFooter = () => { {!isGLDLoggedIn()? ( { : null} : - - - - - + + + + + } diff --git a/src/translations/en.json b/src/translations/en.json index d9b871d4..eaeeea2e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -14,7 +14,7 @@ "bhkLogoAlt": "Brand Hong Kong logo", "iAmSmartAlt": "iAM Smart", - "wcagAaAlt": "Level AA conformance, W3C WAI Web Content Accessibility Guidelines 2.0", + "wcagAaAlt": "Level AA conformance, W3C WAI Web Content Accessibility Guidelines 2.2", "downloadPdfAria": "Download PDF: {guide} ({userType})", "captchaPlayAudio": "Play audio CAPTCHA", diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index 2ef5a197..360ab5b0 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -14,7 +14,7 @@ "bhkLogoAlt": "香港品牌标志", "iAmSmartAlt": "智方便", - "wcagAaAlt": "符合 WCAG 2.0 AA 级别(W3C WAI 网页内容无障碍指引)", + "wcagAaAlt": "符合 WCAG 2.2 AA 级别(W3C WAI 网页内容无障碍指引)", "downloadPdfAria": "下载 PDF:{guide}({userType})", "captchaPlayAudio": "播放语音验证码", diff --git a/src/translations/zh-HK.json b/src/translations/zh-HK.json index 9840202c..51f39df3 100644 --- a/src/translations/zh-HK.json +++ b/src/translations/zh-HK.json @@ -14,7 +14,7 @@ "bhkLogoAlt": "香港品牌標誌", "iAmSmartAlt": "智方便", - "wcagAaAlt": "符合 WCAG 2.0 AA 級別(W3C WAI 網頁內容無障礙指引)", + "wcagAaAlt": "符合 WCAG 2.2 AA 級別(W3C WAI 網頁內容無障礙指引)", "downloadPdfAria": "下載 PDF:{guide}({userType})", "captchaPlayAudio": "播放語音驗證碼",