Skip to content

feat: add device-aware platform ban check for worlds-content-server - #282

Open
LautaroPetaccio wants to merge 4 commits into
mainfrom
feat/world-device-ban-check
Open

feat: add device-aware platform ban check for worlds-content-server#282
LautaroPetaccio wants to merge 4 commits into
mainfrom
feat/world-device-ban-check

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

why

worlds-content-server gates world comms on the public GET /users/:address/bans, which resolves through isPlayerBanned(address) and matches an active ban on the address only.

A ban that recorded a banned_device_id therefore did not stop the same device reconnecting under a different wallet on worlds — even though the explorer already sends its hardware fingerprint as deviceIdentifier in the signed-fetch metadata of that request. The device dimension existed in getActiveBanForConnection, but only /get-scene-adapter and /private-messages/token used it.

what

Adds GET /users/:address/ban-status, a bearer-token service-to-service endpoint (same tokenAuthMiddleware and shape as the existing worldBanCheckHandler) that takes the connection's device id from the X-Device-Id header and resolves it through getActiveBanForConnection, so an active ban matches on the address or the recorded device.

The device id travels in a header, not the query string. components.ts instruments instrumentHttpServerWithRequestLogger, which logs `[${method}: ${pathname}${search}${hash}]` at INFO. A ?deviceId= parameter would therefore persist a stable cross-wallet machine identifier into the logs of every world comms handshake. Headers are not touched by that middleware.

  • src/controllers/handlers/user-moderation/platform-ban-check-handler.ts (new)
  • route + barrel export
  • docs/openapi.yaml, docs/ai-agent-context.md

two deliberate choices

Not added on the public /users/:address/bans. That route is unauthenticated and returns the whole ban row, including banned_device_id. Adding a device parameter there would turn it into a public device-ban oracle on a stable cross-wallet machine identifier. The new endpoint returns only { isBanned } — there is a test asserting the body is exactly that, so the fingerprint is never disclosed.

Returns 500 on failure, not { isBanned: false }. This differs from worldBanCheckHandler, which fails open server-side. The caller retries transient failures and applies its own fail-open; answering "not banned" on a DB blip would silently skip that retry and drop a real ban. Same ultimate fail-open guarantee, strictly better enforcement.

also: recording connection info from worlds

A device ban can only match a device that was recorded first. This service records device + IP inline on its own token paths (/get-scene-adapter, /private-messages/token) and banPlayer snapshots that device onto the ban. World tokens are issued by Worlds Content Server without passing through those paths, so a player who only ever connects to multi-scene worlds had banned_device_id = null and the new check could never match them.

POST /users/:address/connection-info (bearer, schema-validated) lets that caller report the same two values. Two details that matter:

  • The address is lowercased, because banPlayer looks the row up by normalized address — a differently-cased row would be invisible to the snapshot.
  • Field lengths are bounded (device_id is TEXT) since the values originate in client-supplied metadata. Absent fields are COALESCEd by the existing upsert, so an IP-only report never erases a known device.

scope

The explorer's startup blocklist check and both archipelago-workers ban checks are untouched and remain address-only. Archipelago islands cannot participate at all — the v3 protobuf handshake (ChallengeRequest{address} / SignedChallenge{authChainJson}) has no field to carry a device id.

testing

test/integration/user-moderation/platform-ban-check-handler.spec.ts — 16 cases: cross-wallet device match, non-matching device, absent/empty device id, lowercase header name, banned wallet with no device, mixed-case address, device-id-less ban, expired ban, lifted ban, missing/invalid bearer token, and non-disclosure of the ban record.

test/integration/user-moderation/record-connection-handler.spec.ts — 11 cases, including an end-to-end one: record a connection, ban the wallet, then confirm a different wallet on that device is rejected. That test is the one that proves the whole feature, across both endpoints.

Full suite: 90/90 suites, 1193 passed, 2 skipped (--runInBand; the parallel run has unrelated Jest worker crashes from DB contention across workers).

deploy order

Merge and deploy this before decentraland/worlds-content-server#516, which starts calling this endpoint. Until then that caller 404s, retries 3×, and fails open — no lockout, but platform bans are not enforced for worlds in the window.

worlds-content-server gates world comms on the public GET /users/:address/bans,
which matches an active ban on the address only. A ban that recorded a device id
therefore did not stop the same device reconnecting under a different wallet, even
though the explorer already sends its hardware fingerprint as deviceIdentifier in
the signed-fetch metadata of the world comms request.

add GET /users/:address/ban-status, a bearer-token service-to-service endpoint that
takes the connection's ?deviceId= and resolves it through getActiveBanForConnection,
so an active ban matches on the address OR the recorded device.

kept separate from the public /users/:address/bans on purpose: that route is
unauthenticated and returns the whole ban row (including banned_device_id), so
adding a device parameter there would turn it into a public device-ban oracle. the
new endpoint returns only a boolean, and it 500s on failure rather than answering
"not banned", so the caller's retry and fail-open still apply.

the explorer startup check and both archipelago-workers ban checks are untouched
and remain address-only.
@LautaroPetaccio
LautaroPetaccio force-pushed the feat/world-device-ban-check branch from 31cf53c to 7811d5c Compare July 26, 2026 21:20

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one blocking privacy issue with the new service-to-service contract.

Findings:

  • P1 — deviceId is accepted as a query parameter (src/controllers/handlers/user-moderation/platform-ban-check-handler.ts:28, documented in docs/openapi.yaml:1893). Even though the endpoint is bearer-authenticated and returns only { isBanned }, putting a persistent device fingerprint in the URL means it can be captured by ingress/access logs, tracing, proxy logs, retry tooling, and metrics that record request URLs. Please move the device identifier out of the URL, e.g. make this a POST /users/:address/ban-status with a JSON body { "deviceId": "..." }, or accept it via a non-logged service-to-service header, and update worlds-content-server#516 to match.

Security review: no secrets or injection issues found in the changed code; the concern above is sensitive identifier exposure through URL/query logging.

Consumer impact: this is an additive endpoint, and the only new consumer I found is decentraland/worlds-content-server#516. The contract should be changed in both PRs before deployment.

CI: passing.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

the request logger is instrumented in components.ts and writes
`[method: pathname + search + hash]` at INFO, so a ?deviceId= parameter would
persist a stable cross-wallet machine identifier into the logs of every world
comms handshake.

read it from X-Device-Id instead. headers are not touched by that middleware, and
the semantics are unchanged: absent or empty still means "match on address only".
@coveralls

coveralls commented Jul 26, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 30223804152

Coverage decreased (-0.1%) to 85.227%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 6 uncovered changes across 2 files (22 of 28 lines covered, 78.57%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/controllers/handlers/user-moderation/platform-ban-check-handler.ts 11 8 72.73%
src/controllers/handlers/user-moderation/record-connection-handler.ts 11 8 72.73%
Total (5 files) 28 22 78.57%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 3513
Covered Lines: 3102
Line Coverage: 88.3%
Relevant Branches: 1442
Covered Branches: 1121
Branch Coverage: 77.74%
Branches in Coverage %: Yes
Coverage Strength: 76.14 hits per line

💛 - Coveralls

DEVICE_ID_HEADER was exported but the spec hardcoded the string, so the constant
was not actually the contract anyone verified. Bind the case-insensitivity test
to the exported name.
this service records the connecting player's device and IP inline on its own token
paths, and banPlayer snapshots that device so a ban follows the machine across
wallets. world tokens are issued by worlds-content-server without passing through
those paths, so a player who only ever connects to multi-scene worlds had no
recorded device and would be banned with banned_device_id null — the device check
this branch adds could then never match them.

add POST /users/:address/connection-info (bearer, schema-validated) so that caller
can report the same two values. the address is lowercased because banPlayer looks
the row up by normalized address, and a differently-cased row would be invisible to
the snapshot. lengths are bounded since the values originate in client metadata.
@LautaroPetaccio

Copy link
Copy Markdown
Contributor Author

P1 addressed — device id moved out of the URL

Thanks, this was a fair catch. The review landed against 7811d5c; it's resolved as of a72e134.

Took the second of the two suggested options — a service-to-service header rather than a POST body — so the endpoint stays a GET and keeps cache/retry semantics honest for a pure read.

The exposure was real and worse than "commonly captured": src/components.ts:87 instruments instrumentHttpServerWithRequestLogger, whose middleware logs `[${method}: ${pathname}${search}${hash}]` at INFO. That's this service's own logs writing the fingerprint on every world comms handshake, not just a hypothetical upstream proxy. Headers aren't touched by that middleware.

  • Handler reads x-device-id via request.headers.get(); DEVICE_ID_HEADER is exported and the spec asserts against it rather than a duplicated literal.
  • docs/openapi.yaml parameter moved in: queryin: header, with the logging rationale recorded so nobody re-introduces it.
  • feat: enforce device-id platform bans on world comms worlds-content-server#516 updated to match in the same pass; it sanitizes the value to an opaque bounded token before it reaches the header.

Also added since your review: POST /users/:address/connection-info. Checking device bans is useless if no device was ever recorded, and world tokens never pass through this service's own token paths — so a worlds-only player would have been banned with banned_device_id = null. There's an end-to-end test covering record → ban → cross-wallet rejection.

Re: CI — build / test is red on a coveralls.io 503 (This website is under heavy load), after 1193 passed. Unrelated to the diff.

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update — the previous P1 about putting the device id in the query string is resolved. I found one remaining blocker around the new recording flow and the existing public ban DTO.

Findings:

  • P1 — POST /users/:address/connection-info records the worlds caller's device id, and a later banPlayer snapshots that value into user_bans.banned_device_id. That value is still returned by the existing unauthenticated GET /users/:address/bans because banStatusHandler returns the full userModeration.isPlayerBanned() result. So after this PR, a worlds-only player who reports a device id and is banned can have that stable cross-wallet device identifier disclosed publicly by querying the banned wallet's /bans endpoint. Please strip bannedDeviceId from the public ban-status response (and add a regression test against GET /users/:address/bans) before expanding device collection to worlds. The new /ban-status endpoint itself correctly returns only { isBanned }; the leak is through the existing public response path.
  • P2 — X-Device-Id is bounded on POST /connection-info but not on GET /ban-status. Consider applying the same max length/empty normalization before passing it to the DB lookup so the two S2S contracts are consistent.

Security review: no secrets or SQL injection found; bearer auth is applied to both new endpoints and moving the device id to a header fixes the prior URL logging issue. The blocking concern is sensitive identifier disclosure through the existing public ban-status DTO once the new endpoint starts populating worlds devices.

Consumer impact: the new endpoints are additive. I checked the paired worlds-content-server#516 diff and it now sends X-Device-Id as a header and calls POST /users/:address/connection-info, matching this contract. No other consumers were found in org code search.

CI: passing on the current PR checks.


Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack

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.

3 participants