| @@ -0,0 +1,242 @@ | |||||
| # Follow-up: `connect-src` reports for `/api/logout` (404) | |||||
| PROD CSP reports from 2026-10-13 and 2026-10-14. The browser is on | |||||
| `https://pnsps.gld.gov.hk/login` and reports | |||||
| `https://pnsps.gld.gov.hk/api/logout` as a `connect-src` violation with | |||||
| **HTTP 404**. | |||||
| This is not a React route, and it is not a Content-Security-Policy block. | |||||
| The logout API call is reaching something that answers "not found". Chrome | |||||
| then files a report-only CSP violation for that failed same-origin request. | |||||
| Related docs: | |||||
| - [csp-apache.conf.md](./csp-apache.conf.md) — current CSP header | |||||
| - [csp-report-review-2026-09-22.md](./csp-report-review-2026-09-22.md) — earlier PROD review (`status-code: 404` on the SPA shell) | |||||
| --- | |||||
| ## What the report actually says | |||||
| Example (same shape on every hit in this batch): | |||||
| | Field | Value | Meaning | | |||||
| |---|---|---| | |||||
| | `document-uri` | `https://pnsps.gld.gov.hk/login` | The page that was loaded when the report was sent | | |||||
| | `referrer` | usually `https://pnsps.gld.gov.hk/dashboard`; one hit from `https://epaygateway2.gcis.gov.hk/` | Where the user was before `/login` | | |||||
| | `violated-directive` | `connect-src` | Chrome classified the failed request under `connect-src` | | |||||
| | `blocked-uri` | `https://pnsps.gld.gov.hk/api/logout` | The request that failed | | |||||
| | `status-code` | `404` | The server answered. A real CSP block is status `0` | | |||||
| | `disposition` | `report` | Report-Only. The policy did not enforce a block | | |||||
| | Policy | `connect-src 'self'` | Same host is already allowed | | |||||
| Do not add anything to `connect-src` for this. `'self'` already covers | |||||
| `https://pnsps.gld.gov.hk`. | |||||
| --- | |||||
| ## Why this is not a React route | |||||
| React Router only runs after an HTML document has loaded, and only for the | |||||
| address bar (`/login`, `/dashboard`, and the other paths in | |||||
| `src/routes/`). It never sees `axios` / `fetch` calls. | |||||
| `/api/logout` is an API call, not a page: | |||||
| 1. The user is on `/dashboard` (or returns from the payment gateway). | |||||
| 2. Logout runs, or a 401 refresh fails. | |||||
| 3. The app posts to `/api/logout`, then moves the browser to `/login`. | |||||
| Frontend call (`PNSPS-frontend-MaterialUI`): | |||||
| - `src/utils/ApiPathConst.js` — `LOGOUT = "/logout"` | |||||
| - `src/auth/utils.js` — production `apiPath` is `/api` | |||||
| - `src/auth/index.js` — `handleLogoutFunction` does | |||||
| `axios.post(\`${apiPath}${LOGOUT}\`, { refreshToken })`, which is | |||||
| `POST /api/logout` | |||||
| - Callers then `navigate('/login')`. The axios 401 path also does | |||||
| `window.location.reload()` | |||||
| `/login` is already a React route (`src/routes/LoginRoutes.js`, path | |||||
| `login`). The document in the report loaded. The 404 is the POST, not the | |||||
| login page. | |||||
| Adding a React route for `/api/logout` or `/logout` does not receive this | |||||
| POST. The browser sends it to Apache before React runs. | |||||
| --- | |||||
| ## What the backend already does | |||||
| `PNSPS-backend`: | |||||
| - `src/main/resources/application.yml` — `server.servlet.contextPath: /api` | |||||
| - `JwtAuthenticationController` — `@PostMapping("/logout")`, response | |||||
| **204 No Content** | |||||
| - `SecurityConfig` — `LOGOUT_URL = "/logout"` is on `URL_WHITELIST` | |||||
| (`permitAll`), so an expired session can still call it | |||||
| - `RefreshTokenService.revokePresented` — unknown or bad tokens return | |||||
| without an error. A request that hits this method is still **204** | |||||
| So a correct `POST /api/logout` is 204, including a dummy refresh token. | |||||
| **404 means the request never entered that method.** | |||||
| --- | |||||
| ## What to change on Apache | |||||
| Two rules, in this order. `/api` must be proxied to Spring. Page paths that | |||||
| are not real files must be served as `index.html` with **status 200**. | |||||
| ### Step 1. Confirm where `/api` goes today | |||||
| On the PROD Apache host, find the vhost for `pnsps.gld.gov.hk` and note: | |||||
| - `ProxyPass` / `ProxyPassReverse` for `/api` | |||||
| - Any `RewriteRule` that sends unknown paths to `index.html` | |||||
| - Any `ErrorDocument 404` or `FallbackResource` | |||||
| `ErrorDocument 404 /index.html` is the wrong tool for a React app. The body | |||||
| can be the SPA while the status stays **404**. That is the "SPA shell 404" | |||||
| called out in the 2026-08-03 and 2026-09-22 reviews. It also makes a missed | |||||
| API path look like a client route. | |||||
| ### Step 2. Keep `/api` on the backend | |||||
| `/api` must be handled before any SPA rewrite, and must not be rewritten to | |||||
| `index.html`. | |||||
| ```apache | |||||
| ProxyPreserveHost On | |||||
| ProxyPass /api http://127.0.0.1:8090/api | |||||
| ProxyPassReverse /api http://127.0.0.1:8090/api | |||||
| ``` | |||||
| Use the real upstream (host and port) already used for | |||||
| `/api/csp-report` and `/api/login`. Do not invent a new port if PROD is | |||||
| not on `8090`. | |||||
| If a rewrite engine is also on, stop rewriting `/api`: | |||||
| ```apache | |||||
| RewriteEngine On | |||||
| RewriteRule ^/api/ - [L] | |||||
| ``` | |||||
| If `/api/logout` is rewritten to `index.html`: | |||||
| - the refresh token is never revoked | |||||
| - the browser still sees a failed logout call | |||||
| - these CSP reports continue | |||||
| ### Step 3. Serve React routes as 200 `index.html` | |||||
| Apply this only after the `/api` rule. It covers `/login`, `/dashboard`, | |||||
| and the other client paths. Real files (`/static/js/...`, css, images) stay | |||||
| as files. | |||||
| ```apache | |||||
| RewriteEngine On | |||||
| # API is the backend, not a React route. Must stay above the fallback. | |||||
| RewriteRule ^/api/ - [L] | |||||
| RewriteCond %{REQUEST_FILENAME} !-f | |||||
| RewriteCond %{REQUEST_FILENAME} !-d | |||||
| RewriteRule ^ /index.html [L] | |||||
| ``` | |||||
| Remove `ErrorDocument 404 /index.html` for this vhost if it is what currently | |||||
| loads the SPA. `FallbackResource /index.html` is acceptable only when | |||||
| `/api` is excluded and the fallback response is **200**. | |||||
| ### Step 4. Reload Apache and deploy the backend that contains logout | |||||
| The 204 handler has to be in the build that PROD is running. A proxy to an | |||||
| old jar that has no `POST /logout` still returns 404 from Spring. | |||||
| ```bash | |||||
| sudo apachectl configtest | |||||
| sudo systemctl reload apache2 | |||||
| ``` | |||||
| Use `httpd` instead of `apache2` if that is the service name on this host. | |||||
| ### Step 5. Prove the POST hits Spring | |||||
| From a machine that can reach PROD: | |||||
| ```bash | |||||
| curl -sS -D - -o /dev/null \ | |||||
| -X POST "https://pnsps.gld.gov.hk/api/logout" \ | |||||
| -H "Content-Type: application/json" \ | |||||
| -d '{"refreshToken":"not-a-real-token"}' | |||||
| ``` | |||||
| | Status | Meaning | Next action | | |||||
| |---|---|---| | |||||
| | **204** | Handler ran. Bad token is ignored on purpose | Apache and the deployed jar are fine. Go to step 6 | | |||||
| | **404** | Still not the logout method | Response body: Spring JSON means the proxy works but this build has no mapping. HTML (`index.html` or an Apache error page) means the SPA/error fallback caught `/api/logout`. Fix step 2, and deploy the backend if the body is Spring's 404 | | |||||
| | **401** | Request reached Spring Security and was not on the whitelist | Deploy the build where `LOGOUT_URL` is in `URL_WHITELIST` | | |||||
| | **405** | Path exists, method does not match | Something sent GET. The app sends POST. Check for a redirect that turned POST into GET | | |||||
| | **502 / 503** | Proxy target down or wrong | Fix the `ProxyPass` upstream | | |||||
| Also check a real page, not only the API: | |||||
| ```bash | |||||
| curl -sS -D - -o /dev/null "https://pnsps.gld.gov.hk/login" | |||||
| curl -sS -D - -o /dev/null "https://pnsps.gld.gov.hk/dashboard" | |||||
| ``` | |||||
| Both should be **200** and `Content-Type: text/html` (the SPA shell). A | |||||
| **404** here means step 3 is not in effect. | |||||
| ### Step 6. Prove logout in the browser | |||||
| 1. Sign in and open `/dashboard`. | |||||
| 2. Open devtools → Network. Keep "Preserve log" on. | |||||
| 3. Use the header logout control. | |||||
| 4. Expect `POST /api/logout` → **204**, then the document request for | |||||
| `/login` → **200**. | |||||
| 5. Repeat once by waiting for session expiry (the axios path in | |||||
| `src/auth/index.js` posts logout, then `navigate('/login')` and | |||||
| `window.location.reload()`). Same 204, then `/login` 200. | |||||
| The payment-gateway referrer in one report is the same logout POST. The | |||||
| user landed on `/login` after `epaygateway2.gcis.gov.hk`; it is not a | |||||
| separate route to allowlist. | |||||
| ### Step 7. Watch CSP reports | |||||
| After the 204s are confirmed, new `connect-src` hits for | |||||
| `blocked-uri` `https://pnsps.gld.gov.hk/api/logout` should stop. Reports | |||||
| already stored are historical. | |||||
| Leave the CSP header as in [csp-apache.conf.md](./csp-apache.conf.md). | |||||
| Do not add origins, and do not drop `connect-src 'self'`. | |||||
| --- | |||||
| ## What not to change | |||||
| | Idea | Why it does not fix this | | |||||
| |---|---| | |||||
| | New React route for `/logout` or `/api/logout` | The POST never goes through the router. `LogoutPage.js` exists and is not mounted; mounting it would not see `axios.post` | | |||||
| | Rewrite `/api/*` to `index.html` | Logout never revokes the refresh token | | |||||
| | Allow `/api/logout` in CSP | `'self'` already allows it. Status 404 shows the request was sent | | |||||
| | Turn off Report-Only to "hide" it | Hides the symptom. The POST would still 404 | | |||||
| --- | |||||
| ## Code map | |||||
| | Piece | Location | | |||||
| |---|---| | |||||
| | Logout POST | `PNSPS-frontend-MaterialUI/src/auth/index.js` (`handleLogoutFunction`) | | |||||
| | 401 → logout → `/login` → reload | same file, axios response interceptor | | |||||
| | Header logout | `src/layout/MainLayout/Header/index.js` (`handleLogout`) | | |||||
| | API path | `src/utils/ApiPathConst.js`, `src/auth/utils.js` | | |||||
| | Login route | `src/routes/LoginRoutes.js` (`path: 'login'`) | | |||||
| | `POST /logout` → 204 | `PNSPS-backend` `JwtAuthenticationController` | | |||||
| | Whitelist | `SecurityConfig.LOGOUT_URL` | | |||||
| | Context path `/api` | `src/main/resources/application.yml` | | |||||