feat: add device-aware platform ban check for worlds-content-server - #282
feat: add device-aware platform ban check for worlds-content-server#282LautaroPetaccio wants to merge 4 commits into
Conversation
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.
31cf53c to
7811d5c
Compare
decentraland-bot
left a comment
There was a problem hiding this comment.
I found one blocking privacy issue with the new service-to-service contract.
Findings:
- P1 —
deviceIdis accepted as a query parameter (src/controllers/handlers/user-moderation/platform-ban-check-handler.ts:28, documented indocs/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 aPOST /users/:address/ban-statuswith 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".
Coverage Report for CI Build 30223804152Coverage decreased (-0.1%) to 85.227%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - 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.
P1 addressed — device id moved out of the URLThanks, this was a fair catch. The review landed against 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":
Also added since your review: Re: CI — |
decentraland-bot
left a comment
There was a problem hiding this comment.
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-inforecords the worlds caller's device id, and a laterbanPlayersnapshots that value intouser_bans.banned_device_id. That value is still returned by the existing unauthenticatedGET /users/:address/bansbecausebanStatusHandlerreturns the fulluserModeration.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/bansendpoint. Please stripbannedDeviceIdfrom the public ban-status response (and add a regression test againstGET /users/:address/bans) before expanding device collection to worlds. The new/ban-statusendpoint itself correctly returns only{ isBanned }; the leak is through the existing public response path. - P2 —
X-Device-Idis bounded onPOST /connection-infobut not onGET /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
why
worlds-content-servergates world comms on the publicGET /users/:address/bans, which resolves throughisPlayerBanned(address)and matches an active ban on the address only.A ban that recorded a
banned_device_idtherefore did not stop the same device reconnecting under a different wallet on worlds — even though the explorer already sends its hardware fingerprint asdeviceIdentifierin the signed-fetch metadata of that request. The device dimension existed ingetActiveBanForConnection, but only/get-scene-adapterand/private-messages/tokenused it.what
Adds
GET /users/:address/ban-status, a bearer-token service-to-service endpoint (sametokenAuthMiddlewareand shape as the existingworldBanCheckHandler) that takes the connection's device id from theX-Device-Idheader and resolves it throughgetActiveBanForConnection, so an active ban matches on the address or the recorded device.The device id travels in a header, not the query string.
components.tsinstrumentsinstrumentHttpServerWithRequestLogger, 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)docs/openapi.yaml,docs/ai-agent-context.mdtwo deliberate choices
Not added on the public
/users/:address/bans. That route is unauthenticated and returns the whole ban row, includingbanned_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 fromworldBanCheckHandler, 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) andbanPlayersnapshots 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 hadbanned_device_id = nulland 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:banPlayerlooks the row up by normalized address — a differently-cased row would be invisible to the snapshot.device_idisTEXT) 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-workersban 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.