Repository navigation
Require device authentication on the board refresh stream - #15
Open
IbraTech04 wants to merge 1 commit into
Open
IbraTech04 wants to merge 1 commit into
IbraTech04 wants to merge 1 commit into
Conversation
/api/refresh-musallahboard accepted any connection that could name a known, unrevoked ?deviceId=<uuid>. A device id is an identifier, not a credential: the kiosk passes it in its own URL, it is readable off the physical display, and GET /api/admin/board/devices lists it. Anyone holding one could open a socket as that board, which superseded the real kiosk's session (closing it 1000 "superseded") and made a reconnect loop starve the display of refresh pushes, while the attacker received that board's contentChanged / configChanged / refreshAll stream. Origin restriction was not a defence here. setAllowedOrigins constrains browsers only; a non-browser client sends no Origin header at all and Spring accepts the upgrade, which the PoC confirms. The channel now runs the same Ed25519 challenge-response as AgentWebSocketHandler: a server-generated 32-byte SecureRandom challenge per session, a canonical signed string binding sessionId + challenge + deviceId + timestamp under its own domain prefix (musallahboard-stream-auth-v1), a five-minute skew cap, a monotonic seq, and a public key always read from the enrolled Device row rather than from the frame. Unknown, revoked, keyless and badly-signed devices all close 4001 so the code is not a device-id oracle. Unauthenticated sockets are reaped at 4008. Supersede semantics are unchanged, only moved behind authentication: newest-wins on auth success, and the two-arg sessions.remove still stops a late-closing superseded session from evicting its replacement. No schema change, so no migration: the credential already exists on the device row from enrollment. Also from review: - BoardStreamSession.send catches unchecked errors, so one dying board cannot abort a fleet-wide broadcast or fail the admin request that triggered it, and allocates its seq inside the write monitor so concurrent pushes cannot deliver seq 5 before seq 4. - markAuthenticated returns false instead of throwing when the transport drops mid-handshake. - Incoming frames are capped at 4096 characters before parsing. - Revoking a device now closes its refresh-stream session too, not just its agent session. CLIENT_MIGRATION.md documents the new wire protocol for the Raspberry Pi kiosk agent, whose source lives outside this repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Collaborator
Author
|
This has uncovered a more serious flaw that needs further investigation and thought before fully merging/committing to a solution :) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The flaw
/api/refresh-musallahboardperformed no credential check. Its only gates were that?deviceId=<uuid>named a known device and that the device was not revoked:The device id is a v4 UUID, but it is not a secret. The kiosk passes it as a query parameter,
so it sits in the board's own URL and in any screenshot of the display, and
GET /api/admin/board/deviceshands it to every operator. Treating an identifier as acredential is the bug.
WebSecurityConfighas.requestMatchers("/api/refresh-musallahboard").permitAll(), and thesetAllowedOrigins(frontendBaseUrl, musallahBoardBaseUrl)on the handler registration is nota substitute. Origin restriction constrains browsers. A non-browser client sends no
Originheader at all and Spring accepts the upgrade — the PoC below is a plain
java.net.httpWebSocket client with no
Origin, and it connected fine.What an attacker could do
Connect once with a real board's device id, and
sessions.put(deviceId, session)closes thelegitimate kiosk's socket with
1000 superseded. The kiosk reconnects; do it in a loop andthat physical display is permanently starved of refresh pushes and falls back to its
ten-minute poll. Meanwhile the attacker receives that board's
contentChanged,configChangedandrefreshAllstream.What this builds
The channel now runs the same Ed25519 challenge–response
AgentWebSocketHandleralreadyused on
/api/agent/ws:sessionId+challenge+deviceId+timestamp, with its ownversion prefix
musallahboard-stream-auth-v1— deliberately different from the agentchannel's
musallahboard-auth-v1, so a signature made for one channel cannot verify on theother. There is a test for that.
Devicerow. Nothing in the frameinfluences which key is used.
seq,sessionIdecho required.4001. The close code must nottell an unauthenticated caller which device ids are real.
?deviceId=is now ignored entirely.Supersede semantics are preserved, only moved behind authentication. Newest-wins on auth
success, so a reloading kiosk still evicts its own stale socket; and the two-arg
sessions.remove(deviceId, session)still stops a late-closing superseded session fromevicting its replacement. Both have tests.
Why this design over the alternative
The brief offered two options: mirror the challenge–response, or mint a short-lived stream
credential at enrollment. I mirrored the handshake, for three reasons.
the
Devicerow. That key is the credential. A minted token would have meant a new columnon
devices, therefore parallel V10 migrations in bothdb/migration/sqlite/anddb/migration/postgresql/, therefore addl-auto=validatestartup failure as a livefailure mode if either half were wrong. This change ships with zero migrations.
kiosk to carry a minted token is
?token=, which lands in access logs and proxy logs thesame way the device id already did. The signature never appears in a URL.
server-issued, single-use challenge is not — and there is a test asserting a signature
captured on one session fails on another.
The cost is that the client must hold a private key, which the browser-hosted kiosk page
cannot. That is covered in
CLIENT_MIGRATION.md§5.Verification
Tests: 596 pass, 0 failures, 0 errors (
./mvnw test). Baseline onminbar-featureswas575;
BoardStreamHandlerTestgoes from 12 tests to 33, andDeviceAdminControllerTestpicksup an assertion.
BoardStreamHandlerTestcovers both halves the brief asked for explicitly:namingARealDeviceIdWithoutSigningDeliversNothing— the exact pre-fix exploit. Connectswith a real enrolled device id, never signs, and receives no
refreshfrom a broadcast, atargeted push, or a manual refresh.
unauthenticatedConnectionCannotSupersedeALiveBoard— the denial-of-service half. Anunauthenticated connection naming a live board's id does not close it.
authenticatedBoardReceivesAuthOkAndThenPushes— the positive path still works.Plus rejection of: a signature from a key the device does not own, an unknown device, a
revoked device, a device with no key on file, a stale timestamp, a signature replayed from
another session, an agent-channel signature (domain separation), a mismatched
sessionId, anon-monotonic
seq, a replayedseq, an unparseable frame, an unknown frame type, anoversized frame, and re-authentication after
auth_ok.Live server, port 8108, verified end to end. Seeded one enrolled device into a private
copy of the dev DB (the shared
/c/Code/LensBridgeBackend/tree was not modified) and droveit with a throwaway
java.net.httpWebSocket client —wscat/websocatare not installedhere. Not committed.
Attack, sending no
Originheader and naming the real device id:Legitimate board:
Supersede, live — only an authenticated second connection evicts the first:
And an unknown device id closes
4001 auth_failed, identical to a bad signature.Review findings, all fixed
/code-review highreturned six. All are in this branch:BoardStreamSession.sendcaught onlyJsonProcessingExceptionandIOException. Tomcat throws an uncheckedIllegalStateExceptionwhen a socket goes badbetween the
isOpen()check and the write, which would have aborted the broadcast loop soevery board later in iteration order silently missed the refresh, and then escaped
PosterService.createto fail the admin's HTTP request after the poster was already saved.Now catches
Exception. Test:aBoardThatThrowsOnWriteDoesNotStopTheBroadcast.seqwas allocated outside the transport monitor, so two requestthreads pushing to the same board could write seq 5 before seq 4 — a regression a client
validating the sequence would reconnect over. Allocation moved inside the monitor. Test:
outboundSeqIncreasesMonotonicallyAcrossPushes.markAuthenticatedthrewIllegalStateExceptionif the transport droppedmid-handshake. Now returns false and the handler deregisters quietly.
getPayloadLength(), which UTF-8-encodes the wholepayload to answer, allocating a second copy of exactly the oversized frame it was refusing.
Now measures characters.
ceiling near 30 s rather than the documented 15. Reaper interval defaulted to 5 s and the
real ceiling documented.
CLIENT_MIGRATION.mdomitted thesocialsreason thatPromotableSocialMediaServiceactually pushes. Added.While fixing those I also wired
BoardStreamHandler.closeIfPresentintoDeviceAdminController.revoke. Revocation is checked at handshake time, so without it a boardrevoked mid-session kept receiving pushes until it happened to reconnect — the agent channel
already closed on revoke via
AgentSessionRegistry.closeIfPresent, and now that this channelbinds a device identity it owes the same guarantee.
Two items the review raised and I deliberately left: revocation still races the handshake in a
very narrow window (identical shape on the agent channel), and
CloseStatus.SESSION_NOT_RELIABLEis a reserved code that Tomcat remaps (copied verbatim from the existing
AgentSession).Breaking change
CLIENT_MIGRATION.mdis the spec for the Raspberry Pi kiosk agent, whose source is not in thisrepository. It gives the full wire protocol: every frame shape and field rule, the exact bytes
to sign with worked Go and Python examples, ordering, all close codes and what to do about
each, the
Originheader requirement, frame size limits, reconnect rules, and migration order.Close codes changed meaning —
4001wasdevice_id_required,4002unknown_device,4003device_revoked. Anything matching on the old numbers must be updated.There is no dual-accept grace period. Boards fall back to their ten-minute poll in the gap, so
screens go stale rather than blank.
Untouched, as instructed:
AgentWebSocketHandler,/api/agent/ws,config/stomp/StompJwtChannelInterceptor.java, and the frontend repo.🤖 Generated with Claude Code
https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm