Security
The threat model of Elysion, the result of a review of the token flows, the internal API and the edge, and what is known to be wrong. It belongs to PBI #677 (task #678). To report a vulnerability, read SECURITY.md.
Scope and date. The review was done on 2026-10-10 against main plus the security headers of #680 (commit 79aae16), by reading the code and by attacking the running compose stack (pnpm dev:stack, Traefik on port 80, Keycloak on the loopback port 8081, the users dev1 and dev2). Every row of the review table says what was tried and what came back; the commands can be run again on a stack of your own. The Helm chart was read, not run. This is a review of a pre-release by its developers, not an audit by a third party.
The compose stack is a demo (Keycloak in start-dev, development secrets, plain HTTP, ADR 0028). What follows judges the design and the code, and says "demo only" where a finding exists only because of a development default.
Threat model
Assets
| Asset | Where it lives | Why it matters |
|---|---|---|
| Board content | Yjs state in Postgres (BoardDocuments, bytea), in memory in the realtime service | The product. Confidentiality (who may read) and integrity (who may write) |
| Board images | The object store (RustFS in the stack), behind the business backend | Same as the content |
| Membership and roles | Postgres (BoardMemberships, RoomMemberships) | They decide who may read and write; the WS token copies the role at connect |
| Templates | Postgres; private to their owner, built-in ones are public | Content of the owner |
| Identities | Keycloak (its own database): passwords, refresh tokens, the realm | Everything else follows from them. Services never see a password |
| Access token | The browser's memory (Keycloak JWT, 5 minutes, audience elysion-bff) | Acts as the person on every /api call |
| Refresh token | The browser (30 minutes of session idle in the demo realm) | Mints new access tokens |
| WS token | The browser, the URL of one WebSocket upgrade (HS256, 60 s, one board, one role) | Opens one board with the role it carries |
WS_TOKEN_SECRET | BFF and realtime environment | Whoever has it mints a WS token for any board with any role |
INTERNAL_API_SECRET | Realtime and business backend environment | Whoever has it reads, writes and deletes any board document (ADR 0017) |
| Database, Valkey, S3 credentials | Environment of the services | Direct access to everything above |
| Logs | stdout, optionally the log viewer (ADR 0025) | Must hold no token, header or query string |
Entry points
| Entry point | Reachable from | Authentication |
|---|---|---|
http://<host>/ (frontend, nginx) | Anybody who reaches the edge | None (the app must load to start the login). Static files and the security headers |
/api/* (BFF, through Traefik) | Anybody who reaches the edge | Access token, checked by Traefik's forwardAuth (GET /api/auth/verify) and again by the BFF and the business backend |
POST /api/csp-report | Anybody who reaches the edge | None, on purpose (a browser sends a report without a token); rate limited, body limited |
/yjs (realtime, WebSocket) | Anybody who reaches the edge | WS token in the query string, checked at the handshake only; no rate limit |
/health of the services | The compose network (not routed) | None |
/metrics of each service | The compose network (not routed) | None |
Traefik :8082 (metrics); :8080 (dashboard and API, only with docker-compose.dev.yml) | The compose network; :8080 also the host's loopback with docker-compose.dev.yml | None |
/internal/* of the business backend | The compose network (not routed) | Service token signed with INTERNAL_API_SECRET |
Keycloak :8081 (login, admin console) | The host's loopback (KEYCLOAK_BIND) | Keycloak's own; the demo admin is admin / admin |
Valkey :6379, Postgres :5432, S3 :9000 | The compose network | None (Valkey), the password elysion (Postgres), development keys (S3) |
Browser: imported .excalidraw files, templates | The user's own tab | The person chose the file |
Trust boundaries
- The Internet and the edge. Traefik is the only published port (80). Everything behind it is reached through a router with a middleware chain (
security-headers,cors,rate-limit,bff-authon/api). NoX-Forwarded-*header of a caller is trusted. - The browser and the page. The page is trusted to hold the access token in memory; the Content-Security-Policy of #680 is the second line against script injection. A board is data from other people: it is rendered by Excalidraw and never executed.
- The compose network (the cluster). Inside it, services trust their network: Valkey has no password, the metrics and health routes are open, the backend is protected by its own tokens only. A workload on this network is as good as inside. The Helm chart narrows this with network policies; the compose file does not and is documented as a demo.
- The service to service tokens. Each service checks the token it acts on: the business backend validates the Keycloak JWT itself and authorizes by membership;
/internalaccepts only the realtime service's token (another authentication scheme, another issuer and audience).
Attacker positions and what each can do
| Position | Can do | Cannot do |
|---|---|---|
| Anonymous, from the Internet | Load the app; post CSP reports (rate limited); open unlimited WebSocket upgrades that are closed with 4401 at once; read the public parts of Keycloak (login page, JWKS). | Call any /api route (401 at the edge, the BFF and the backend), reach /internal or /metrics, open a board, forge an identity header. |
| A signed-in person (any board role) | Everything their roles allow, on the boards they are a member of: viewers read, editors write, owners administer. Create boards and templates (no per-user quota, finding). | See another board, template or room; get a WS token for a board without a role (403). |
| A removed or demoted member | Keep their open WebSocket for up to MEMBERSHIP_RECHECK_MS (15 s) (finding F1, fixed in #772), then the socket is closed or lowered. Cannot fetch a new WS token (403) or call /api for the board. | Reconnect after the connection drops. |
| Stolen access token | Everything the person can do through /api for up to 5 minutes (the token is not revocable: services validate the signature and the expiry, nothing else), and exchange it for WS tokens for the person's boards. No refresh. | Anything after expiry; access to /internal (another scheme, 401 verified). |
| Stolen refresh token | Mint access tokens for the rest of the session (30 minutes idle, up to the SSO maximum). The realm decides; services do not see it. | n/a (Keycloak revokes it with the session). |
| Stolen WS token | Open one board with the role in the token for the remaining seconds of its life; the token was not single-use (two sockets with one token both opened, fixed in #772) and the socket stays open as long as the person has a role (checked every 15 s). | Open another board (4403), keep the token past 60 s for a new connection, call /api. |
Leaked WS_TOKEN_SECRET | Mint a WS token for any board, any role, any sub (confirmed: a forged owner token opened a board). The identity is not looked up. The realtime service trusts the claim. | Read the database or call the backend: the WS token is no credential there. |
Leaked INTERNAL_API_SECRET | Read, overwrite and delete the stored document of any board through /internal (confirmed from the compose network), and store a document for a board id that does not exist. Users' content is lost or replaced without a trace. | Use /api, and no user identity (no sub is checked). |
| A position on the compose network | Everything above and more: read /metrics of all services; read Traefik's API on :8080; talk to Valkey without a password and inject Yjs updates into any board (confirmed, F4); reach Postgres with the demo password and RustFS with the demo keys. | Be a user: /api and /internal still want a token (with the two secrets above it can make one). |
| Host access to the docker socket | Traefik mounts it read-only; read-only still lets a process that controls Traefik list every container and its environment (secrets). A compromised Traefik is a compromised host. | n/a |
| Keycloak admin | Everything: any user, any role, any token. | n/a. The demo's admin / admin is bound to the loopback. |
The three token flows follow identity and ADR 0017. The design is sound in the properties the review looked for: no service trusts an identity header (the BFF only sets X-Auth-User-Id, nothing reads it), every service validates its token itself, a WS token is bound to one board, and /internal has its own scheme. Its weakness is time: the WS token and the role in it are checked once.
The review
Each row: what was reviewed, what was done, the result. "Refused" is a pass. Commands assume T=$(curl -s http://localhost:8081/realms/elysion/protocol/openid-connect/token -d grant_type=password -d client_id=elysion-frontend -d username=dev1 -d password=dev1 | jq -r .access_token) and the stack from pnpm dev:stack.
| # | Review item | What was tried | Result |
|---|---|---|---|
| 1 | WS token in the URL: logs | Connected five times with a valid token and grepped the logs of Traefik, BFF, realtime, business backend and frontend for eyJhbGci (docker compose logs <svc> | grep -c eyJhbGci). | No token in any log. Traefik logs no query (queryParameters: drop) and, since #734, no path either (RequestPath: drop, invitation links carry a secret in it), the realtime service logs the refusal reason, never the token. Not a finding. A proxy in front of Traefik logs the query unless told not to: see the list below. |
| 2 | WS token in the URL: referrers | Read the headers of every response (curl -sI http://localhost/): Referrer-Policy: no-referrer; the upgrade is no navigation and carries no Referer of its own. | Not a finding. The token is also short-lived and bound to one board. |
| 3 | WS token replay and forgery | Node ws client: no token, abc, alg: none, a payload edited without re-signing, a token for another board, an expired token (waited 62 s), the same token on two sockets. | Refused with 4401 (no, garbage, none, tampered, expired) and 4403 (other board). The same token opened two sockets: there is no jti or single-use rule. Within 60 s a stolen token is as good as the original (F1, impact). |
| 4 | 60 s lifetime vs an open connection | Added dev2 as editor, opened a socket, DELETE /api/boards/<id>/members/<dev2> (204), kept writing for 72 s; then demoted an editor to viewer with PATCH, then deleted the board, each with the socket open. | Confirmed finding F1. The removed member's writes at +2 s and +72 s were merged and visible to the owner; the demoted viewer kept writing; after DELETE /api/boards/<id> both sockets stayed open. A new WS token for the removed member is refused (403). |
| 5 | forwardAuth header spoofing | curl http://localhost/api/boards -H 'X-Auth-User-Id: 0000…0001' -H 'X-Auth-User-Email: a@b.c' with no token; with a valid token and a spoofed X-Auth-User-Id, GET /api/auth/verify; read the BFF for readers of the headers. | Refused: 401 without a token; with one the answer carries the id from the token (8e32d537…), not the spoofed one (Traefik replaces the header). No code reads X-Auth-User-*; the backend takes the identity from the JWT. Not a finding. |
| 6 | /internal not routed at the edge | curl -X PUT|DELETE|GET http://localhost/internal/boards/x/document, /api/internal/..., /metrics, /api/metrics, /dashboard/, /openapi/v1.json, /yjs/metrics. | Not reachable: the paths either fall to the frontend (200 text/html, the single-page app; 405 for PUT and DELETE) or are 401 under /api (and 404 for /yjs/metrics). /internal with a user's token from inside the network: 401. |
| 7 | /internal and a leaked secret | From the realtime container, signed an internal token with the demo INTERNAL_API_SECRET and called http://business-backend:8080/internal/boards/<id>/document. | GET 200 (the board's state). A PUT with If-None-Match: * for a board id that does not exist returned 204 (a document without a board, F7). The secret is the whole defense; rotating it is the recovery. |
| 8 | Rate limit scope | 400 parallel requests to /api/boards with and without a random X-Forwarded-For; the same against /api/csp-report. | 114 pass, 286 are 429 in both cases: X-Forwarded-For does not bypass the limit (Traefik counts the TCP peer). Behind a load balancer the peer is the balancer, so everybody shares one budget (config, not run: the chart's edge.yaml sets no ipStrategy); see the list below. /yjs has no limit by design: 300 unauthenticated upgrades in parallel all opened and were closed with 4401. |
| 9 | WebSocket message size | A frame of 16 MiB + 1 byte on an authorized socket; 16 MiB on an unauthenticated one; 3 updates of 11 MiB on one authorized socket. | Over 16 MiB: closed with 1009. The unauthenticated 16 MiB frame: closed with 4401. The 33 MiB of updates were accepted: there is no document limit (F2). |
| 10 | Document size | Pushed 33 MiB into one board; watched business-backend and realtime. | Confirmed finding F2. Every save then fails: the backend answers 500 (a BadHttpRequestException, "Request body too large. The max request body size is 33554432 bytes", not a 413), the realtime service retries every 30 s for ever and the board cannot be saved again. |
| 11 | Template Scene (5 MB of JSON) | POST /api/templates with a valid scene of exactly 5,000,000 characters (201 in 0.2 s), one of 5,000,001 (400), GET of the 5 MB template (200, 5,000,171 bytes), a JSON nested 100,000 deep (400). | The limit holds on both ends (BFF body limit 6 MB, backend 5,000,000 characters, System.Text.Json depth limit). Parsing cost is small. No limit on the number of templates a person may store (F6). |
| 12 | JSON parsing of imported files in the browser | Read apps/frontend-canvas/src/board-io.ts, importFile, insertFile: loadFromBlob from Excalidraw, which restores the elements; the only other JSON.parse is token-safe-storage.ts. Templates reach the same path but are private to their owner. | Not run in a browser. There is no size or element limit on a file; a person who imports a huge file stops their own tab and writes the elements into the shared document (F2, the size). The parsed data is rendered by Excalidraw, never evaluated. Not a separate finding. |
| 13 | Keycloak admin console | ss -ltn and docker compose ps: 127.0.0.1:8081->8080. Master realm token with admin / admin. | Bound to the loopback by default (KEYCLOAK_BIND); the demo password works from the host. A demo property; ADR 0028 and self-hosting cover the real one. |
| 14 | Metrics and other internal ports | From the host: curl localhost:8082/metrics, localhost:8080 (connection refused). From the BFF container: wget of traefik:8082/metrics, bff:3000/metrics, realtime:3000/metrics, business-backend:8080/metrics, traefik:8080/api/rawdata, rustfs:9001. | Not reachable from outside the host (nothing is published; /metrics is not routed). All answer 200 without a token from inside the network, including Traefik's API. Expected for a scrape target; see the attacker-position table and F8 for the dev overlay. |
| 15 | Containers: root or not | docker compose exec -T <svc> id for all services; the USER of the four Dockerfiles; ps for Postgres and Valkey; the chart's securityContext. | BFF and realtime 1000, business backend 1654, frontend 101 (nginx-unprivileged), Keycloak 1000, RustFS 10001; Postgres and Valkey start as root and drop to their own user. Traefik runs as root with the docker socket (F9). The chart: runAsNonRoot, read-only root file system, seccomp, dropped capabilities, network policies. |
| 16 | Dependencies | pnpm audit --prod; dotnet list package --vulnerable --include-transitive in apps/business-backend. | One high advisory (braces through Excalidraw's sass, a build-time file watcher, no patched release), already allowlisted in pnpm-workspace.yaml with its reason; no vulnerable NuGet package. See Accepted advisories. |
| 17 | POST /api/csp-report (public) | 400 parallel posts; a 50 kB body; a body with a quote and a newline in blocked-uri; 5.5 MB and 7 MB of application/json. | Rate limited (112 pass, 288 429). Log injection fails (the value is JSON-escaped in the line). A 50 kB application/csp-report is dropped unread. A 5.5 MB application/json body is accepted and parsed (7 MB: 413), and up to 10 violations per request are logged (F5). |
| 18 | Frontend security headers (#680) | curl -sI http://localhost/ and /api/boards; preflight from Origin: http://evil.example. | All set as the spec says (CSP enforced, nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Permissions-Policy, COOP). Preflight from another origin gets no Access-Control-Allow-Origin. Three weak spots: connect-src allows any wss: host, style-src 'unsafe-inline', and the server banners (X-Powered-By: Express, nginx/1.31.6): F10. |
| 19 | Cross-site WebSocket hijacking | Opened /yjs with Origin: http://evil.example and a valid token. | Accepted (no Origin check), and harmless: the page of another site has no token and cannot read one, since the token is not a cookie. Not a finding. |
| 20 | Valkey | From the realtime container: PING, KEYS elysion:*, PUBLISH elysion:doc:<board> with a valid envelope while a client watched the board. | Confirmed finding F4: no password, the update appeared on the client without any token. A malformed envelope is JSON.parsed without a guard; not run, because it may stop the service (F4). |
| 21 | Stolen access token and Keycloak flows | Read the token's claims (exp - iat = 300 s, aud elysion-bff), the realm (directAccessGrantsEnabled: true on elysion-frontend, sslRequired: none), and called /internal with a user token. | A token is valid for 5 minutes and is not revocable by the services. /internal with a user token: 401. The password grant and sslRequired: none are demo settings (ADR 0028). |
Confirmed findings
Severity is for a production deployment; "demo only" says when the cause is a development default. Each becomes a [Bug] issue under #677.
| ID | Finding | Severity | Area | Fix |
|---|---|---|---|---|
| F1 | An open WebSocket keeps its role after the membership was removed, the role lowered or the board deleted (#772) | High | area:realtime | Fixed in #772: the realtime service asks the backend every 15 s who still has a role and closes (4403) or lowers the socket; a WS token has a jti and opens one socket. Chosen over a push (a Valkey channel from the backend) because it needs no new coupling and works with several instances. |
| F2 | No limit on the size of a board: past 32 MiB the backend answers 500 and the board can never be saved again (#773) | Medium | area:realtime | Fixed in #773 (#800): the realtime service refuses an update that takes the state past MAX_DOCUMENT_BYTES (ADR 0026) with the board-full message, the backend answers 413, and RealtimeSaveFailing alerts on failing saves. |
| F3 | A permanently failing save is retried for ever and the room is never released (deleted board: 404; too large: 500) (#774) | Low | area:realtime | Fixed in #774: 404, 410, 400, 413 and 422 end the retries (the sockets are closed, the room unloaded); an empty room is given up after 30 failures. |
| F4 | Valkey has no password and any workload on the network can inject updates and presence into any board (#775) | Medium | area:infra | Fixed in #775 as far as the repository can: the compose Valkey and the kind one have a password (REDIS_URL carries it), the chart's README and the self-hosting guide require one and a NetworkPolicy for the production Valkey (which is not in the chart), and a malformed envelope is dropped instead of thrown. Envelopes are validated, not signed: a holder of the password can still publish. |
| F5 | The public POST /api/csp-report accepts a 6 MB JSON body and logs ten warnings per request (#776) | Low | area:bff | Fixed in #776: the global 6 MB JSON parser skips the route (isReadAsJson), the controller reads at most 8 kB and only application/csp-report and application/reports+json, and logs one line per request ((+n more in this report)). |
| F6 | No limit on how many templates (5 MB each) or boards a person can store (#777) | Low | area:business-backend | Fixed in #777: quotas per person (100 templates, 20,000,000 characters of scenes, 1,000 owned boards) answering 409; rooms still have none. |
| F7 | The internal API stores a document for a board id that does not exist, and deleting a board leaves its document (14 orphans in the test stack) (#778) | Low | area:business-backend | Fixed in #778: PUT answers 404 for a UUID board id that has no board (ids that are no UUID, the demo room default, are not checked); a delete removed the document in the same transaction already; a migration deletes the orphans that exist (15 of 17 rows in the test stack). |
| F8 | docker-compose.dev.yml publishes Postgres, the Traefik dashboard and RustFS on all interfaces (demo/dev only) (#779) | Low | area:infra | Fixed in #779: the host ports of docker-compose.dev.yml listen on DEV_BIND (default 127.0.0.1); a kind cluster needs DEV_BIND=0.0.0.0 (infra/kind/README.md, container.yml). |
| F9 | Traefik runs as root with the docker socket mounted, and its API (insecure: true) answers on the network without a token (demo only) (#780) | Low | area:infra | Partly fixed in #780: api.insecure: false and the dashboard is routed only by docker-compose.dev.yml (on the loopback); the plain stack answers 404 on :8080. Accepted for the demo: Traefik still runs as root with the docker socket read-only, because a socket proxy would still allow inspect, which returns every container's environment. ADR 0028 removes the socket in production (the Kubernetes CRD provider). |
| F10 | Banners and a loose CSP: X-Powered-By: Express, nginx/<version>, connect-src allows any wss:, style-src 'unsafe-inline' (#781) | Low | area:frontend | Fixed in #781: no X-Powered-By (BFF, realtime), server_tokens off (nginx), no Server: Kestrel (backend), and connect-src without wss: ('self' covers the page's own /yjs). Still open: style-src 'unsafe-inline' (Angular and Excalidraw inject styles; a nonce needs per-request index.html). |
Not a finding, because
- The WS token in the URL. It is in no log (Traefik drops the query, the services log the reason only),
Referrer-Policy: no-referrerand the upgrade is not a navigation, it lives 60 s and opens one board. The residual risk is a reverse proxy or a load balancer in front of Traefik that logs full URLs: configure it not to log the query of/yjs(this is a deployment note for self-hosting). X-Auth-User-Idset by a client. Traefik replaces it on the forwarded request, and the BFF does not read it: it takes the identity from the token's claims./internalat the edge. Not routed; falls to the frontend. The user's token does not open it.X-Forwarded-Forand the rate limit. Traefik does not trust it and counts the peer. A reverse proxy in front needs Traefik'sforwardedHeaders.trustedIPsand anipStrategyon the limit, or all users share one budget; that is a topology decision for ADR 0028.- Template
Sceneof 5 MB. Bounded on both ends, parsed once for validation. Templates are private to the owner, so a hostile scene reaches only its author. - Imported files. The user's own choice; Excalidraw restores the data and never evaluates it, SVG images are refused, and the CSP forbids inline script. The consequence of a huge file is the size problem of F2, not a code execution.
- Cross-site WebSocket hijacking. There is no ambient credential (no cookie); the token must be known.
- Keycloak admin
admin/admin, the password grant,sslRequired: none, no TLS, development secrets indocker-compose.yml. The demo is documented as such (SECURITY.md, self-hosting, ADR 0028); the admin console is on the loopback. - Open
/metricsand/healthon the network. They are scrape targets, not published; the network policies of the chart restrict them to the monitoring namespace. They hold counts and route names, no user data or secrets. - Postgres and Valkey start as root. Their entrypoints drop to the
postgresandvalkeyusers (psshows it); only the entrypoint is root. - An access token that works until it expires. The design (stateless validation, 5 minutes, ADR 0014) accepts it; a stolen token is a 5-minute problem. The long-lived socket is not the same thing (F1).
- Voting is secret. The votes are kept by the business backend (ADR 0029): while a voting runs a person gets their own votes only, and the result has names only when the facilitator started a named voting. (Before ADR 0029 the votes were in the board's document and readable by every client.)
- The local copy of a board for working offline (#735). While the connection is down the canvas keeps the board's document in the browser's IndexedDB (
elysion-board:<boardId>:<generation>) so that a closed tab loses nothing. It holds what the person was allowed to read, never a token, it is removed once the changes are sent and at logout, and a read-only canvas stores nothing. On a shared computer it is data on the device until then: log out when you leave (the same is true of the browser's own cache). - The people search shows names and emails to everybody who has logged in (#663).
GET /users?query=answers up to 10 people whose name or email contains at least two typed characters; it cannot list the directory and needs a login. Within one instance that is what a Share dialog needs, but it also tells every signed-in person which colleagues have an account. A deployment that wants less narrows the one query behind it (IUserRepository.SearchAsync, for example to people who share a room with the caller); this is open for the owner to decide. - No HSTS or TLS in the demo.
HSTS_MAX_AGEis for deployments with TLS.
Audit log
Who gave or took away access is written down (#669, business-backend.md, "Audit log"): members of boards and rooms, group roles on rooms, groups, the groups a person gets from the identity provider at sign-in, and invitation links. The entry is saved in the same transaction as the change. It holds ids, roles, counts and group names; never a token (an invitation link's secret is not in any entry), an email address or what is on a board. It is readable by the instance administrators (metadata only, as everything under /admin) and, for one board or room, by its owners. Entries are kept AUDIT_RETENTION_DAYS (365 by default). The log is not tamper-proof against someone with write access to the database; for that, ship the structured logs (each entry carries the request id of its log lines) to a store the database operators cannot change.
Accepted advisories
pnpm audit --prod --audit-level high ignores one advisory, listed with its reason in pnpm-workspace.yaml (auditConfig.ignoreGhsas): GHSA-vfj7-8cjw-p6xm (braces, stack exhaustion on deeply nested patterns, no patched release). It is reached as @excalidraw/excalidraw > sass > chokidar > braces, Sass's file watcher, which the built bundle does not run and no service loads. It is looked at again with every Renovate update of Excalidraw (ADR 0024). The image scan's allowlist is .trivyignore; it is empty, and an entry needs a reason and a date.
Scanning in CI
Three checks run on every pull request and on main (the ci.yml jobs scan and audit, and codeql.yml; a release waits for the first two):
- Image scan: each of the four images is built as a release would publish it and scanned by Trivy. A CRITICAL vulnerability that has a fix fails the check; HIGH ones are listed in the log. The allowlist is
.trivyignore(empty; an entry needs a reason and a date above it). Base images change without a commit of ours: a red scan on an untouched branch means rebuild or take the update. Reproduce:docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy image --severity CRITICAL --ignore-unfixed <image>. - Dependency audits:
pnpm audit --prod --audit-level high(the allowlist isauditConfig.ignoreGhsasinpnpm-workspace.yaml, see "Accepted advisories") anddotnet list package --vulnerable --include-transitive(any hit fails). - CodeQL for TypeScript and C#, on pull requests, on
mainand weekly; results are under Security > Code scanning.
Renovate keeps closing the gap (ADR 0024). The day this was set up the scan found no CRITICAL vulnerability in any image and one HIGH (libtiff in the frontend's Alpine base, fixed upstream).
Re-running the review
Everything above can be repeated on a running stack. The scripts used were small Node programs on top of ws and yjs (the realtime service's dependencies): open a socket with a WS token, change the membership with DELETE /api/boards/<id>/members/<userId>, write to the Yjs map and read the board from a second connection. After F1 is fixed, the first of these must end with the socket closed within seconds of the DELETE. Delete the boards and templates a run creates.