screentinker/server/lib/device-sanitize.js
ScreenTinker cbc00515e2 Scope device serialization to what each endpoint actually needs
A device row carries two fields that are not ordinary data: device_token, the
credential the player proves with on the /device socket, and settings_pin, which
unlocks the player's on-device settings menu and so hands physical control of the
panel to anyone holding it.

device_token was already stripped everywhere. settings_pin was not — it went out on
both the collection and the detail endpoint. The dashboard does show it, but on one
screen only: the device detail page, which fetches a single device. The collection
endpoint had no consumer for it and was returning the PIN for every device in the
workspace on every load.

The detail endpoint keeps it, so that page is unchanged. The list no longer sends it.
Same data, much smaller blast radius, no feature lost.

Tests pin the split in both directions — absent from the list, present on the detail,
and the socket credential absent from both (asserted on the whole serialized payload,
not just the top-level key, so a nested echo would fail too).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:40 -05:00

31 lines
1.3 KiB
JavaScript

'use strict';
// Security: never return a device's WebSocket auth secret to API/dashboard
// clients. `device_token` is the credential the device proves with (validated
// via crypto.timingSafeEqual on the /device socket); leaking it to any
// workspace user enables device impersonation. Strip it from every device row
// before it leaves the server.
function stripDeviceSecrets(d) {
if (!d || typeof d !== 'object') return d;
delete d.device_token;
return d;
}
// List responses additionally drop `settings_pin`.
//
// The PIN unlocks the player's on-device settings menu (2x Back), i.e. physical control of
// the panel. The dashboard genuinely needs it — but only on ONE screen, the device detail
// page, which fetches a single device via GET /api/devices/:id. The collection endpoint was
// handing out the PIN for EVERY device in the workspace on every load, to every member,
// with no consumer for it. Same data, far wider blast radius, for nothing.
//
// So: detail keeps it (the feature is unchanged), the list does not. If a future list view
// needs the PIN, fetch the device rather than widening this.
function stripDeviceSecretsForList(d) {
const row = stripDeviceSecrets(d);
if (row && typeof row === 'object') delete row.settings_pin;
return row;
}
module.exports = { stripDeviceSecrets, stripDeviceSecretsForList };