Topic: mcp server integer overflow security
MCP server integer overflow security — Number.MAX_SAFE_INTEGER, BigInt for large IDs, and arithmetic overflow in size calculations
JavaScript represents all numbers as 64-bit IEEE 754 doubles, which can represent integers exactly only up to 2^53 − 1 (9,007,199,254,740,991). Above that limit, integer precision is lost silently — two different large integers become the same floating-point value. For MCP servers that process database IDs from Snowflake, Twitter/X, or large PostgreSQL sequences, or that compute buffer sizes and offset arithmetic, this precision loss is a security vulnerability: IDOR via ID collision, incorrect access control checks, and exploitable buffer under-allocation.
Pattern 1: Large ID precision loss — IDOR via 64-bit integer collisions
Many high-throughput systems use 64-bit integer IDs: Snowflake IDs (used by Twitter/X, Discord, Instagram), PostgreSQL sequences on heavily-used tables, and distributed ID generators like ULID encoded as integers. When these IDs arrive as JSON numbers, JSON.parse() converts them to JavaScript Numbers — and any ID above 2^53 loses precision. Two distinct IDs can become the same JavaScript Number. An MCP server that uses the parsed Number for an access control check may grant access to the wrong resource — a classic IDOR vulnerability caused by floating-point imprecision rather than a logic bug.
WRONG — parsing large IDs as JSON Numbers
// WRONG: JSON.parse treats large integer as Number — precision lost above 2^53
const payload = JSON.parse('{"userId": 9007199254740993}');
console.log(payload.userId); // => 9007199254740992 (WRONG — off by 1)
console.log(payload.userId === 9007199254740992); // => true — IDs collide!
async function getUser(req, res) {
const { userId } = req.body; // WRONG: already imprecise if > 2^53
if (userId !== req.session.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
const user = await db.users.findById(userId); // WRONG: querying with imprecise ID
res.json(user);
}
RIGHT — keep large IDs as strings; use BigInt for arithmetic
// RIGHT: parse JSON with a reviver that keeps large integers as strings
function jsonParseWithBigInt(text) {
// Replace large integer literals in JSON with quoted strings before parsing
return JSON.parse(
text.replace(/:\s*(-?\d{16,})/g, (_, n) => `: "${n}"`)
);
}
const payload = jsonParseWithBigInt('{"userId": 9007199254740993}');
console.log(payload.userId); // => "9007199254740993" (string — exact)
console.log(typeof payload.userId); // => "string"
async function getUser(req, res) {
const userId = String(req.body.userId); // normalize to string
const sessionUserId = String(req.session.userId);
if (userId !== sessionUserId) { // RIGHT: string comparison — exact
return res.status(403).json({ error: 'Forbidden' });
}
// Pass as string to DB driver — PostgreSQL bigint columns accept string input
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
res.json(user.rows[0]);
}
Pattern 2: Arithmetic overflow in size calculations — buffer under-allocation
Buffer size calculations that multiply or add large integers can overflow or produce unexpected results when the inputs are not validated. A common pattern is computing the total size of a multi-part upload: partCount * partSize. If both values are near Number.MAX_SAFE_INTEGER / 2, the product overflows to Infinity or a near-zero floating-point value. A size of 0 from wraparound silently allocates a zero-byte buffer — causing writes to either corrupt memory or fail silently.
WRONG — unchecked size arithmetic
// WRONG: no validation on part count or size — attacker can cause overflow
async function allocateUploadBuffer(partCount, partSize) {
const totalSize = partCount * partSize; // WRONG: can overflow to Infinity or 0
const buffer = Buffer.allocUnsafe(totalSize); // WRONG: no size validation
return buffer;
}
RIGHT — validate inputs and use BigInt for intermediate arithmetic
const MAX_PART_COUNT = 10_000;
const MAX_PART_SIZE = 100 * 1024 * 1024; // 100 MB
const MAX_TOTAL_SIZE = 1024 * 1024 * 1024; // 1 GB hard cap
async function allocateUploadBuffer(partCount, partSize) {
// RIGHT: validate inputs are safe integers before any arithmetic
if (!Number.isSafeInteger(partCount) || partCount < 1 || partCount > MAX_PART_COUNT) {
throw new RangeError(`partCount must be 1–${MAX_PART_COUNT}`);
}
if (!Number.isSafeInteger(partSize) || partSize < 1 || partSize > MAX_PART_SIZE) {
throw new RangeError(`partSize must be 1–${MAX_PART_SIZE} bytes`);
}
// RIGHT: use BigInt for the multiplication to avoid overflow
const totalSizeBig = BigInt(partCount) * BigInt(partSize);
if (totalSizeBig > BigInt(MAX_TOTAL_SIZE)) {
throw new RangeError(`Total size ${totalSizeBig} exceeds limit of ${MAX_TOTAL_SIZE}`);
}
// RIGHT: convert back to Number only after confirming it's in safe range
return Buffer.allocUnsafe(Number(totalSizeBig));
}
Pattern 3: Numeric input validation — rejecting unsafe integers at the boundary
MCP tool arguments that accept numeric IDs, page sizes, offsets, or counts should validate that the values are safe integers before using them in business logic. Accepting 2^53 + 1 as a page offset and passing it to a database query may work on some DB drivers (which convert to a string internally) and fail silently on others (which use a 32-bit int). Rejecting values above Number.MAX_SAFE_INTEGER at the tool boundary gives callers an explicit error rather than propagating a silently imprecise value.
RIGHT — safe integer assertion helper for tool argument validation
// validation.js — reusable safe integer validator
export function assertSafeInteger(value, name, min = 0, max = Number.MAX_SAFE_INTEGER) {
if (typeof value !== 'number') {
throw new TypeError(`${name} must be a number, got ${typeof value}`);
}
if (!Number.isFinite(value)) {
throw new RangeError(`${name} must be finite, got ${value}`);
}
if (!Number.isSafeInteger(value)) {
throw new RangeError(
`${name} (${value}) exceeds Number.MAX_SAFE_INTEGER — pass as string or use BigInt`
);
}
if (value < min || value > max) {
throw new RangeError(`${name} must be ${min}–${max}, got ${value}`);
}
return value;
}
// Tool handler using the validator
server.tool('list_records', {
offset: z.number(),
limit: z.number(),
}, async ({ offset, limit }) => {
assertSafeInteger(offset, 'offset', 0, 10_000_000);
assertSafeInteger(limit, 'limit', 1, 1000);
const records = await db.query(
'SELECT * FROM records LIMIT $1 OFFSET $2',
[limit, offset]
);
return records.rows;
});