Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | 4x 4x 4x 4x 4x 4x 4x 48x 48x 192x 38x 48x 4x 51x 51x 51x 2x 49x 1x 48x 48x 48x 48x 48x 48x 45x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 48x 6x 42x 1x 41x 6x 6x 35x 35x 48x 47x 6x 6x 47x 12x 47x 47x 47x 1x 1x 4x | /**
* Device info endpoint — accepts device info from mobile clients,
* enriches with IP geolocation, stores in Firestore, checks bans.
*
* POST /api/device-info → Submit device info
*/
const router = require('express').Router();
const { db } = require('../utils/firebase');
const { now } = require('../utils/helpers');
const { isValidDeviceId } = require('../utils/deviceId');
const {
checkBans,
clearBanCache,
countBoundDevices,
rollbackBindingIfOverCap,
MAX_BOUND_DEVICES,
BINDING_TRANSACTION_OPTIONS,
} = require('../utils/bans');
const { getIpGeo } = require('../utils/ip-geo');
const log = require('../utils/log');
// ─── Helpers ─────────────────────────────────────────────────────
// ─── Route ───────────────────────────────────────────────────────
/**
* Drop keys whose value is absent, so a `{ merge: true }` write LEAVES the
* stored value alone instead of overwriting it with null.
*
* Firestore treats an explicit `null` under merge as "set this field to
* null" — the same as any other value. Only OMITTING the key preserves what
* is there. Empty strings count as absent: `getIpGeo` already maps ip-api's
* `as: ""` (an unrouted address) to null, and an empty ASN would be stored
* looking recorded while matching nothing.
*
* @param {Record<string, unknown>} doc
* @returns {Record<string, unknown>} a new object with the absent keys gone
*/
function withoutAbsent(doc) {
const out = {};
for (const [key, value] of Object.entries(doc)) {
if (value === null || value === undefined || value === '') continue;
out[key] = value;
}
return out;
}
router.post('/device-info', async (req, res) => {
try {
const body = req.body;
if (!body?.deviceId) {
return res.status(400).json({ error: 'deviceId is required' });
}
if (!isValidDeviceId(body.deviceId)) {
// Reject `/` (path redirection), whitespace, over-length, non-string —
// deviceId is used directly as a Firestore doc id (SHY-0170).
return res.status(400).json({ error: 'deviceId is invalid' });
}
const { deviceId } = body;
// The REAL edge IP. `req.ip` respects `trust proxy: 1` (index.js), so
// the value is the rightmost X-Forwarded-For entry — the one appended
// by OUR edge, not a client-forgeable leftmost decoy. Never parse the
// XFF header directly here: the old leftmost-split let a forged
// `X-Forwarded-For: <clean-ip>, <real-ip>` evade network bans (SHY-0149).
const ip = req.ip;
// Enrich with IP geolocation
const geo = await getIpGeo(ip);
// Build device doc.
//
// SHY-0299: absent values are OMITTED, never written as null. The write
// below is `tx.set(..., { merge: true })`, and under merge an explicit
// null OVERWRITES the stored value — it is not the same as leaving the
// key out. So `asn: geo.asn || null` replaced a known-good ASN with null
// on any request whose geo lookup failed, and `bans.js` builds its ASN
// list with `.filter((asn) => !!asn)` — a nulled binding contributes no
// ASN, so an `asn`-typed ban stopped matching that device. With
// `getUserDeviceStanding` caching the standing for 5 minutes, one blip
// disabled ASN-ban matching for that device for up to ~5.5 minutes.
//
// SHY-0143's negative cache made it deterministic rather than
// intermittent: a failed lookup is now held for 30 seconds, so every
// launch inside that window nulled the field, where previously each
// request re-rolled and a success could repair it.
//
// The geo fields are LAST-KNOWN telemetry: none has a "clear it" use
// case, and a device that genuinely changes network gets a new value on
// the next successful lookup.
const timestamp = now();
const baseDoc = {
deviceId,
uniqueId: req.auth.uniqueId,
manufacturer: body.manufacturer || null,
model: body.model || null,
osVersion: body.osVersion || null,
screenResolution: body.screenResolution || null,
screenDensity: body.screenDensity || null,
totalRamMb: body.totalRamMb || null,
appVersion: body.appVersion || null,
buildNumber: body.buildNumber || null,
locale: body.locale || null,
networkType: body.networkType || null,
carrierName: body.carrierName || null,
firebaseInstallationId: body.firebaseInstallationId || null,
// The record of THIS request: nothing to preserve, and omitting them
// would be the bug in reverse.
lastIp: ip,
lastSeenAt: timestamp,
// GEO ONLY. The body-derived fields above keep writing null when
// absent, deliberately: `stores null for optional fields that are not
// provided` is an existing, intentional contract with its own test, and
// those fields have no security consumer. Reversing it here would be an
// unrelated behaviour change smuggled in under a ban-matching fix.
...withoutAbsent({
isp: geo.isp,
asn: geo.asn,
country: geo.country,
region: geo.region,
}),
};
// Both spellings matter: getIpGeo returns {} on a hard failure (undefined)
// and null for a success that carried no ASN.
if (geo.asn === null || geo.asn === undefined) {
// Answers "why is this device still banned?" from logs. The stored ASN
// is deliberately left in place, so the ban that matches it is not
// visible in this request's data.
log.debug('device-info', 'geo unresolved — preserving any stored geo fields', { deviceId });
}
const docRef = db.doc(`deviceBindings/${deviceId}`);
// The cap check runs OUTSIDE the transaction: a count needs a query read,
// and a query read inside a bind transaction breaks the document-level
// conflict detection the device-lock depends on (see
// BINDING_TRANSACTION_OPTIONS). A race can slip past this pre-check —
// `rollbackBindingIfOverCap` below closes that window.
//
// At the cap this route does NOT refuse: it records the telemetry but never
// claims the device. An unowned doc carries no uniqueId, so it can never be
// a decoy, and the response still carries `banStatus` — refusing outright
// would blank the very ban screen this endpoint exists to feed.
const caller = req.auth.uniqueId;
const callerRegistered = caller !== null && caller !== undefined;
const atCap = callerRegistered && (await countBoundDevices(caller)) >= MAX_BOUND_DEVICES;
let capped = false;
let bound = false;
await db.runTransaction(async (tx) => {
const deviceDoc = { ...baseDoc };
capped = false;
bound = false;
const existing = await tx.get(docRef);
const data = existing.exists ? existing.data() || {} : null;
const owner = data ? (data.uniqueId ?? data.userId ?? null) : null;
if (!existing.exists) deviceDoc.firstSeen = timestamp;
if (owner !== null) {
// SHY-0170: device-info updates telemetry on every launch, but must NEVER
// silently re-bind a device already owned by another account to the caller
// — that would defeat the device-lock (see /api/devices/lock-check). An
// already-owned device needs no cap check: claiming it costs no new slot.
if (String(owner) !== String(caller)) delete deviceDoc.uniqueId;
} else if (!callerRegistered) {
// A not-yet-registered caller (valid token, no users doc yet) must never
// claim a device — the rule /devices/lock-check already enforces.
// Otherwise the doc stored a literal `uniqueId: null` alongside a
// boundAt, implying an ownership that can never resolve.
delete deviceDoc.uniqueId;
} else if (atCap) {
// UNOWNED and the caller is full: record telemetry, claim nothing.
// Keyed on ownership, not existence — an unowned doc this route wrote
// earlier must not be bindable for free on a second call (R3-C1).
capped = true;
delete deviceDoc.uniqueId;
} else {
deviceDoc.boundAt = timestamp;
bound = true;
}
tx.set(docRef, deviceDoc, { merge: true });
}, BINDING_TRANSACTION_OPTIONS);
if (bound && (await rollbackBindingIfOverCap(caller, deviceId))) {
// A concurrent bind pushed the account past the cap; the claim was
// released. Telemetry stays; the device is simply unclaimed.
bound = false;
capped = true;
}
if (capped) {
log.warn('device-info', 'device-binding cap reached — telemetry stored unbound', {
uniqueId: caller,
deviceId,
});
}
// A newly-bound device can carry a hardware ban, changing the caller's
// standing — drop their cached verdict so the gate sees it immediately.
if (bound) clearBanCache(caller);
// Check bans
const banStatus = await checkBans(deviceId, ip, geo.asn || null);
res.json({ success: true, banStatus });
} catch (err) {
log.error('device-info', 'Error processing device info submission', { error: err.message });
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
|