Ver código fonte

api 404 issue

CR026
Jason Chuang 3 semanas atrás
pai
commit
9694d83ca0
1 arquivos alterados com 242 adições e 0 exclusões
  1. +242
    -0
      docs/csp-api-logout-404-follow-up.md

+ 242
- 0
docs/csp-api-logout-404-follow-up.md Ver arquivo

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

Carregando…
Cancelar
Salvar