| @@ -6,7 +6,9 @@ Adjust `report-uri` if your API base path or host differs (UAT example below; PR | |||||
| See also: | 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 | - [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 | ## Enforcing | ||||
| @@ -13,6 +13,7 @@ Related app docs: | |||||
| - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy | - [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-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`) | |||||
| --- | --- | ||||
| @@ -14,6 +14,7 @@ Related app docs: | |||||
| - [csp-apache.conf.md](./csp-apache.conf.md) — Apache header snippets to deploy | - [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-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`) | |||||
| --- | --- | ||||
| @@ -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 | |||||
| @@ -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. | |||||
| @@ -58,7 +58,7 @@ const AuthFooter = () => { | |||||
| {!isGLDLoggedIn()? ( | {!isGLDLoggedIn()? ( | ||||
| <a | <a | ||||
| href="https://www.w3.org/WAI/WCAG2AA-Conformance" | href="https://www.w3.org/WAI/WCAG2AA-Conformance" | ||||
| title="Explanation of WCAG 2 Level AA conformance" | |||||
| title={wcagAlt} | |||||
| > | > | ||||
| <img | <img | ||||
| height="32" | height="32" | ||||
| @@ -408,11 +408,11 @@ const MultiPaymentWindow = (props) => { | |||||
| </Grid> | </Grid> | ||||
| : null} | : null} | ||||
| </Grid>: | </Grid>: | ||||
| <Grid container direction="row" justifyContent="center" alignItems="center"> | |||||
| <FormLabel sx={{ fontSize: "20px", color: "#000000", textAlign: "center"}}> | |||||
| <FormattedMessage id="paymentMethodNotAvailable"/> | |||||
| </FormLabel> | |||||
| </Grid> | |||||
| <Grid container direction="row" justifyContent="flex-start" alignItems="center"> | |||||
| <FormLabel sx={{ fontSize: "20px", color: "#000000", textAlign: "left" }}> | |||||
| <FormattedMessage id="paymentMethodNotAvailable"/> | |||||
| </FormLabel> | |||||
| </Grid> | |||||
| } | } | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} md={12}> | <Grid item xs={12} md={12}> | ||||
| @@ -14,7 +14,7 @@ | |||||
| "bhkLogoAlt": "Brand Hong Kong logo", | "bhkLogoAlt": "Brand Hong Kong logo", | ||||
| "iAmSmartAlt": "iAM Smart", | "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})", | "downloadPdfAria": "Download PDF: {guide} ({userType})", | ||||
| "captchaPlayAudio": "Play audio CAPTCHA", | "captchaPlayAudio": "Play audio CAPTCHA", | ||||
| @@ -14,7 +14,7 @@ | |||||
| "bhkLogoAlt": "香港品牌标志", | "bhkLogoAlt": "香港品牌标志", | ||||
| "iAmSmartAlt": "智方便", | "iAmSmartAlt": "智方便", | ||||
| "wcagAaAlt": "符合 WCAG 2.0 AA 级别(W3C WAI 网页内容无障碍指引)", | |||||
| "wcagAaAlt": "符合 WCAG 2.2 AA 级别(W3C WAI 网页内容无障碍指引)", | |||||
| "downloadPdfAria": "下载 PDF:{guide}({userType})", | "downloadPdfAria": "下载 PDF:{guide}({userType})", | ||||
| "captchaPlayAudio": "播放语音验证码", | "captchaPlayAudio": "播放语音验证码", | ||||
| @@ -14,7 +14,7 @@ | |||||
| "bhkLogoAlt": "香港品牌標誌", | "bhkLogoAlt": "香港品牌標誌", | ||||
| "iAmSmartAlt": "智方便", | "iAmSmartAlt": "智方便", | ||||
| "wcagAaAlt": "符合 WCAG 2.0 AA 級別(W3C WAI 網頁內容無障礙指引)", | |||||
| "wcagAaAlt": "符合 WCAG 2.2 AA 級別(W3C WAI 網頁內容無障礙指引)", | |||||
| "downloadPdfAria": "下載 PDF:{guide}({userType})", | "downloadPdfAria": "下載 PDF:{guide}({userType})", | ||||
| "captchaPlayAudio": "播放語音驗證碼", | "captchaPlayAudio": "播放語音驗證碼", | ||||