Skip to content

Reject cross-site websocket upgrades and API calls - #847

Open
yuzi-co wants to merge 1 commit into
sipeed:mainfrom
yuzi-co:security/websocket-origin-check
Open

Reject cross-site websocket upgrades and API calls#847
yuzi-co wants to merge 1 commit into
sipeed:mainfrom
yuzi-co:security/websocket-origin-check

Conversation

@yuzi-co

@yuzi-co yuzi-co commented Aug 1, 2026

Copy link
Copy Markdown

All five websocket upgraders accept any Origin:

  • service/vm/terminal.go
  • service/ws/service.go
  • service/stream/direct/h264.go
  • service/stream/webrtc/h264.go
  • service/picoclaw/gateway_proxy.go

each with CheckOrigin: func(r *http.Request) bool { return true }, and no endpoint checks it either. The session is carried by a cookie.

Why that matters

A page the operator visits while logged in can open a websocket to the device and act as them. /api/vm/terminal is a root shell. /api/ws injects keystrokes. Plain POST endpoints are reachable the same way.

The only thing standing in the way today is the browser default for SameSite, which is not something to rely on: Firefox does not apply SameSite=Lax by default, so on Firefox this is exploitable as it stands.

What this does

  • middleware.SameOrigin compares the Origin host against the request host, and all five upgraders use it.
  • The auth middleware applies the same check, so it covers plain API calls and not just upgrades.
  • The token cookie is set SameSite=Strict, and Secure when served over https.

A request with no Origin header is allowed. Non-browser clients do not send one, and they are not what this defends against; browsers always send it on cross-origin requests.

Only the hostname is compared. The device serves the same UI over http and https, so an origin that arrives on the other port is still the same device. Both sides read the host through url.URL.Hostname, so a port or the brackets around an IPv6 literal are stripped identically on each.

Tests

middleware/origin_test.go and middleware/jwt_test.go cover same-origin, cross-origin, absent header, IPv6 hosts, and port and scheme mismatches.

Verified with go build, go vet, go test, and GOOS=linux GOARCH=riscv64 go build. Tests need -tags novision (#846) to run off-device.


Updated: the webrtc read limit that was originally bundled here has been split into its own PR, as offered above. This PR is now only the origin check.

All five websocket upgraders accepted any Origin, and no endpoint checked it
either, while the session is carried by a cookie. A page the operator visits
while logged in could therefore open the root shell at /api/vm/terminal,
inject keystrokes over /api/ws, or POST to any endpoint. Only the browser's
SameSite default stood in the way, and Firefox does not apply one.

The auth middleware now rejects a request whose Origin names a different host,
the upgraders use the same check through middleware.SameOrigin, and the token
cookie is set with SameSite=Strict, plus Secure when served over https.

A request with no Origin header is allowed: non-browser clients do not send
one, and they are not what this defends against.

Only the hostname is compared. The device serves the same UI over http and
https, so an origin that arrives on the other port is still the same device.
Both sides read it through url.URL.Hostname, so a port or the brackets around
an IPv6 literal are stripped identically on each.
@yuzi-co
yuzi-co force-pushed the security/websocket-origin-check branch from 9d40430 to 10b1918 Compare August 13, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant