main
4
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
da32e49b0b |
fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
Enabling push in production failed with "Validation failed": the browser's PushSubscription.toJSON() carries expirationTime, and it was sent verbatim to a schema that is strict about unknown fields. The client now sends exactly endpoint + keys. The test doubles had been shaped like the schema rather than like a browser, which is why 1,112 tests said nothing. The CSP hash for index.html's inline theme script was hardcoded in the container's start-up script behind a comment asking for it to be regenerated by hand, and claiming a test asserted it; none did. The bytes are what the browser hashes, so an edit or a checkout with different line endings would have silently blocked the page's own script. The hash is now emitted by `npm run build` from the index.html that is actually shipped, copied into the image, and read at start; the container refuses to start without it. Verified against the built image: header hash equals served-script hash. For the record: a CSP console message in production is what led here, and the served page turned out to be correct - that message was a browser extension's injected script, which the policy is right to refuse. |
||
|
|
ca3862d5a7 |
harden(docker)!: run nginx unprivileged and stop dropping security headers
BREAKING CHANGE: the production container listens on 8080 instead of 80. nginx now runs as an unprivileged user and cannot bind a privileged port without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to this container must be repointed - in the homelab Traefik config, traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080. nginx's add_header is not additive across levels: a block declaring any add_header of its own discards every header inherited from its parent. Both /assets/ and /config.js declared one, so X-Frame-Options, X-Content-Type-Options and Referrer-Policy were silently absent from every JavaScript and CSS response. The headers now come from a snippet included in each location - verified 6/6 on /, on a hashed asset bundle, and on /config.js. Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at container start from API_URL, because the API is deliberately not same-origin in production; asserting same-origin would have broken the deployment outright. The inline theme script is allowed by SHA-256 hash rather than unsafe-inline, which would have bought almost nothing - editing that script means regenerating the hash. config.js is also no longer rendered with envsubst, which does no escaping: an API_URL containing a double quote closed the JS string literal and the remainder executed on every page load. Addresses M31, M34. |
||
|
|
92d7dfdef3 |
fix(frontend): repair session teardown and effect lifecycles
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of H11. Steps 5 and 7 of the package were already done in WP-1. An expired token left the app half signed in: the interceptor only wrote to the console, so the nav still showed the user's name while every request failed. A 401 now clears the token and navigates to sign-in - a full navigation rather than a router push, so in-memory state and open sockets go with the page, and the API client does not have to reach into the stores and join an import cycle with them. A 401 from /auth/login is excluded: there, it is an answer. Signing out disconnected the notifications socket and nothing else. Chat, versus and game night stayed open holding a token that had just been removed, and the friends and game stores kept their contents, so signing in as someone else on the same tab showed the previous person's friend list. Teardown now lives in one place, because the failure mode was precisely that adding a socket did not mean remembering to close it. All four connect() guards tested `.connected` rather than existence, so a socket that was mid-reconnect was replaced without being closed and the orphan went on handling every event a second time. disconnect() likewise cleared only the socket, leaving matchId, result and score behind - the next match opened showing the previous match's completion modal. Effects depended on the whole `user` object, which loadUser() rebuilds on every call, so they re-ran when nothing had changed. In AsyncVersusGame that effect's cleanup calls resetGame(), so reloading a profile mid-match reset the board. They depend on the id now. The same effect also reset the store while its fetch was in flight, so init() then wrote the match back into what had just been cleared; it aborts before resetting. Page reads take an AbortSignal and their effects abort on unmount. Several already had a `cancelled` flag, which stops the state write but lets the request run to completion; the flag stays to guard the `finally` blocks. Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight against a server that removes one link per call, and a failure was logged while the local chain had already been shortened. Undos are serialised and the server's chain - which it was already returning and the client was discarding - is now authoritative. The runtime API setting was substituted into a JS string literal by envsubst, which does no escaping, so a value containing a quote ended the literal and the remainder executed on every page load. It is escaped at render time (verified under busybox with the production values and with an injection payload, which comes out inert), and a malformed value is rejected on read. Note this deliberately does not assert same origin, as the plan suggested: the frontend and API are served from different subdomains in production, so that assertion would break the deployment. The Endless difficulty selector is removed rather than wired. Difficulty is derived from cast analysis on the server and Endless builds its pairs in the browser, so there was nothing to wire it to - it silently did nothing while telling the player their choice mattered. Verified in the running app: an invalid token clears and redirects to /login?expired=1 without looping, while a wrong password shows "Invalid email or password" on the form and stays put; sign-out clears the token, returns to sign-in and leaves no trace of the previous user; Endless renders without the control and keeps its real filters. No console errors beyond the 401s those checks deliberately provoked. |
||
|
|
7d0947d295 |
Add Keep-Me-Signed-In, movie release dates, and lint cleanup (#1)
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / lint (push) Successful in 15s
frontend-ci / typecheck (push) Successful in 18s
frontend-ci / sast (push) Successful in 12s
frontend-ci / fs-scan (push) Successful in 13s
frontend-ci / build (push) Successful in 40s
frontend-ci / push (push) Failing after 34s
Reviewed-on: #1 |