Data and security all 2 written
The SQLite database built into Node, and the injection, traversal and permission mistakes that turn a working program into a vulnerable one.
39 SQLite, built in
The API in lesson 37 kept its data in a Map, which is gone the
moment the process stops. A database keeps data on disk and lets you ask questions
of it. SQLite is a complete SQL database that lives in a single file, with no server to run, and Node
ships it as node:sqlite. It is the database in every phone and every browser, and
plenty of real websites run on it.
A table, and some rows
example.mjs
import { DatabaseSync } from "node:sqlite";
import { existsSync } from "node:fs";
const db = new DatabaseSync("shop.db"); // the file is created if it does not exist
db.exec(`
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL -- in pence: never store money as a float
)
`);
const insert = db.prepare("INSERT INTO products (name, price) VALUES (?, ?)");
console.log(insert.run("Tea", 350));
console.log(insert.run("Coffee", 420));
insert.run("Cake", 275);
const rows = db.prepare("SELECT * FROM products ORDER BY price").all();
console.log(rows);
db.close();
console.log("shop.db exists:", existsSync("shop.db"));
check("three rows, cheapest first", rows.map((r) => r.name), ["Cake", "Tea", "Coffee"]);
check("each row is a plain record of the columns", { ...rows[0] }, { id: 3, name: "Cake", price: 275 });
What node printed, when the build ran it
node example.mjs
{ changes: 1, lastInsertRowid: 1 }
{ changes: 1, lastInsertRowid: 2 }
[
[Object: null prototype] { id: 3, name: 'Cake', price: 275 },
[Object: null prototype] { id: 1, name: 'Tea', price: 350 },
[Object: null prototype] { id: 2, name: 'Coffee', price: 420 }
]
shop.db exists: true
ok three rows, cheapest first
ok each row is a plain record of the columns
exec runs SQL that returns nothing, like creating a table. For everything else you
prepare a statement once and run it as many times as you like, and the ?
marks are placeholders: the values are passed separately and never become part of the SQL
text. Always use them; lesson 40 shows what happens to code that pastes values into SQL instead.
run reports how many rows changed and the id of the new one.
Rows come back as objects whose keys are the column names. Node prints them as
[Object: null prototype] because they are made with no prototype at all (lesson 17), so a
column called constructor or toString cannot collide with anything
inherited. You use them like any other object; spreading one, as the check does, gives an ordinary
object.
Asking questions
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:"); // a database in memory, gone at exit
db.exec(`
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price INTEGER NOT NULL, category TEXT);
INSERT INTO products (name, price, category) VALUES
('Tea', 350, 'drinks'), ('Coffee', 420, 'drinks'), ('Cake', 275, 'food'), ('Soup', 590, 'food');
`);
// get(): the first row, or undefined.
const byId = db.prepare("SELECT name, price FROM products WHERE id = ?");
console.log("id 2:", byId.get(2));
console.log("id 99:", byId.get(99));
// Named parameters, for readability.
const cheaper = db.prepare("SELECT name FROM products WHERE category = :category AND price < :max");
console.log("drinks under 400:", cheaper.all({ category: "drinks", max: 400 }).map((r) => r.name));
// SQL does the adding up.
const totals = db.prepare(`
SELECT category, count(*) AS items, avg(price) AS average
FROM products GROUP BY category ORDER BY category
`).all();
console.log(totals);
check("a missing row is undefined, not an error", byId.get(99), undefined);
check("named parameters matched", cheaper.all({ category: "drinks", max: 400 }).map((r) => r.name), ["Tea"]);
check("the database did the arithmetic", totals.map((t) => [t.category, t.items, t.average]),
[["drinks", 2, 385], ["food", 2, 432.5]]);
What node printed, when the build ran it
node example.mjs
id 2: [Object: null prototype] { name: 'Coffee', price: 420 }
id 99: undefined
drinks under 400: [ 'Tea' ]
[
[Object: null prototype] {
category: 'drinks',
items: 2,
average: 385
},
[Object: null prototype] {
category: 'food',
items: 2,
average: 432.5
}
]
ok a missing row is undefined, not an error
ok named parameters matched
ok the database did the arithmetic
get returns one row, or undefined when there is none, so "not found" is
an ordinary value to check rather than an exception. all returns every row as an array;
for a result too big to hold, iterate() hands them over one at a time, the same idea as
reading a file line by line in lesson 35. And the last query is the reason SQL is worth learning:
grouping, counting and averaging happen inside the database, which is faster and much less code than
loading every row into JavaScript to do it by hand.
The data outlives the process
db.mjs
import { DatabaseSync } from "node:sqlite";
export const db = new DatabaseSync("notes.db");
db.exec("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, text TEXT NOT NULL)");
add.mjs
import { db } from "./db.mjs";
const { lastInsertRowid } = db.prepare("INSERT INTO notes (text) VALUES (?)").run(process.argv[2]);
console.log("saved note", lastInsertRowid);
list.mjs
import { db } from "./db.mjs";
for (const note of db.prepare("SELECT id, text FROM notes ORDER BY id").iterate()) {
console.log(`${note.id}. ${note.text}`);
}
The terminal, as the build ran it
node add.mjs "buy tea" saved note 1 node add.mjs "call Ada" saved note 2 node list.mjs 1. buy tea 2. call Ada ls add.mjs db.mjs list.mjs notes.db
Each command is a new process that starts, writes or reads, and exits. The notes are still there
for the next one because they are in notes.db. CREATE TABLE IF NOT EXISTS
lets every program set up the table safely whether or not an earlier one already did. (Real projects
manage changes to their tables with numbered migration files, applied in order; the idea is
the same.)
Constraints: rules the database enforces
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id));
`);
db.prepare("INSERT INTO users (email) VALUES (?)").run("ada@example.com");
const refused = [];
function attempt(label, sql, ...values) {
try {
db.prepare(sql).run(...values);
console.log(`${label}: accepted`);
} catch (err) {
refused.push(err.errcode);
console.log(`${label}: ${err.code} ${err.errcode}, "${err.message}"`);
}
}
attempt("same email twice ", "INSERT INTO users (email) VALUES (?)", "ada@example.com");
attempt("no email at all ", "INSERT INTO users (email) VALUES (?)", null);
attempt("order for user 99", "INSERT INTO orders (user_id) VALUES (?)", 99);
attempt("order for user 1 ", "INSERT INTO orders (user_id) VALUES (?)", 1);
console.log("foreign keys switched on:", db.prepare("PRAGMA foreign_keys").get().foreign_keys === 1);
check("UNIQUE, NOT NULL and the foreign key each refused a bad row", refused, [2067, 1299, 787]);
What node printed, when the build ran it
node example.mjs
same email twice : ERR_SQLITE_ERROR 2067, "UNIQUE constraint failed: users.email" no email at all : ERR_SQLITE_ERROR 1299, "NOT NULL constraint failed: users.email" order for user 99: ERR_SQLITE_ERROR 787, "FOREIGN KEY constraint failed" order for user 1 : accepted foreign keys switched on: true ok UNIQUE, NOT NULL and the foreign key each refused a bad row
A constraint is a rule the database checks on every write, whichever program does the writing, so
it holds even when your JavaScript has a bug. A failed write throws an error with
code: "ERR_SQLITE_ERROR", and errcode says which rule was broken: 2067 for a
duplicate in a UNIQUE column is the one to catch when two people sign up with the same
email, and answer with a 409 Conflict. REFERENCES makes a foreign key: an order
must belong to a user that exists. Node switches foreign keys on for every database it opens — the
sqlite3 command-line tool, and many other libraries, leave them off unless asked.
Types, and three surprises
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE loose (age INTEGER);
CREATE TABLE strict (age INTEGER) STRICT;
`);
const outcomes = {};
function attempt(label, work) {
try {
const result = work();
outcomes[label] = "ok";
console.log(`${label.padEnd(34)} ok${result === undefined ? "" : ", " + JSON.stringify(result)}`);
} catch (err) {
outcomes[label] = err.name;
console.log(`${label.padEnd(34)} ${err.name}: ${err.message}`);
}
}
attempt("text into a loose INTEGER column", () => {
db.prepare("INSERT INTO loose (age) VALUES (?)").run("not a number");
return { ...db.prepare("SELECT age, typeof(age) AS stored_as FROM loose").get() };
});
attempt("text into a STRICT INTEGER column", () => db.prepare("INSERT INTO strict (age) VALUES (?)").run("not a number"));
attempt("a boolean", () => db.prepare("INSERT INTO strict (age) VALUES (?)").run(true));
attempt("undefined", () => db.prepare("INSERT INTO strict (age) VALUES (?)").run(undefined));
attempt("an integer past 2 ** 53", () => db.prepare("SELECT 9007199254740993 AS big").get());
const big = db.prepare("SELECT 9007199254740993 AS big");
big.setReadBigInts(true);
console.log("the same, read as a BigInt:", big.get().big);
check("an ordinary table stored text in an INTEGER column", outcomes["text into a loose INTEGER column"], "ok");
check("a STRICT one refused it", outcomes["text into a STRICT INTEGER column"], "Error");
check("booleans and undefined cannot be stored", [outcomes["a boolean"], outcomes["undefined"]], ["TypeError", "TypeError"]);
check("a number too big for JavaScript is an error, not a wrong answer", outcomes["an integer past 2 ** 53"], "RangeError");
check("BigInt reads it exactly", big.get().big, 9007199254740993n);
What node printed, when the build ran it
node example.mjs
text into a loose INTEGER column ok, {"age":"not a number","stored_as":"text"}
text into a STRICT INTEGER column Error: cannot store TEXT value in INTEGER column strict.age
a boolean TypeError: Provided value cannot be bound to SQLite parameter 1.
undefined TypeError: Provided value cannot be bound to SQLite parameter 1.
an integer past 2 ** 53 RangeError: Value is too large to be represented as a JavaScript number: 9007199254740993
the same, read as a BigInt: 9007199254740993n
ok an ordinary table stored text in an INTEGER column
ok a STRICT one refused it
ok booleans and undefined cannot be stored
ok a number too big for JavaScript is an error, not a wrong answer
ok BigInt reads it exactly
SQLite is relaxed about types by default: a column's type is a preference, and a value that does
not fit is stored anyway, as whatever it is. Adding STRICT to a table makes it enforce
the types you declared, and there is little reason not to on a new table. On the JavaScript side,
only numbers, strings, null, BigInt and bytes can be stored. A boolean is a
TypeError — SQLite has no boolean type, so store 1 and 0 — and so is
undefined, which usually means a missing field; use null on purpose. The
last one is a kindness: an integer too big for a JavaScript number (lesson 5) is refused with a
RangeError rather than quietly rounded, and setReadBigInts(true) reads it
exactly.
Transactions: all or nothing
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE accounts (name TEXT PRIMARY KEY, balance INTEGER NOT NULL CHECK (balance >= 0)) STRICT;
INSERT INTO accounts VALUES ('ada', 100), ('alan', 50);
`);
function transaction(db, work) {
db.exec("BEGIN");
try {
const result = work();
db.exec("COMMIT");
return result;
} catch (err) {
db.exec("ROLLBACK"); // undo everything since BEGIN
throw err;
}
}
const change = db.prepare("UPDATE accounts SET balance = balance + ? WHERE name = ?");
function transfer(from, to, amount) {
transaction(db, () => {
change.run(amount, to); // pay in first...
change.run(-amount, from); // ...then take out, which the CHECK may refuse
});
}
const balances = () => db.prepare("SELECT name, balance FROM accounts ORDER BY name").all()
.map((row) => `${row.name} ${row.balance}`).join(", ");
transfer("ada", "alan", 30);
console.log("after sending 30: ", balances());
let refusal;
try {
transfer("ada", "alan", 500);
} catch (err) {
refusal = err.message;
console.log("sending 500 failed:", refusal);
}
console.log("after the failure:", balances());
check("the second transfer was refused by the CHECK", refusal, "CHECK constraint failed: balance >= 0");
check("and changed nothing: alan was not paid the 500", balances(), "ada 70, alan 80");
What node printed, when the build ran it
node example.mjs
after sending 30: ada 70, alan 80 sending 500 failed: CHECK constraint failed: balance >= 0 after the failure: ada 70, alan 80 ok the second transfer was refused by the CHECK ok and changed nothing: alan was not paid the 500
The failed transfer paid Alan 500 first, and only then discovered Ada could not afford it, when
the CHECK constraint refused to take her balance below zero. Without a transaction Alan
would have kept the 500. Inside one, ROLLBACK undoes every change since
BEGIN, so either every statement in a transaction happens or none of them does. Any
operation that is really several writes — an order and its items, a transfer, a sign-up that creates
two rows — belongs in one.
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("events.db");
db.exec("CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT NOT NULL)");
const insert = db.prepare("INSERT INTO events (name) VALUES (?)");
let started = performance.now();
for (let i = 0; i < 1000; i++) insert.run(`event ${i}`);
const separately = performance.now() - started;
started = performance.now();
db.exec("BEGIN");
for (let i = 0; i < 1000; i++) insert.run(`event ${i}`);
db.exec("COMMIT");
const together = performance.now() - started;
console.log(`1,000 inserts, each its own transaction: ${separately.toFixed(0)} ms`);
console.log(`1,000 inserts in one transaction: ${together.toFixed(1)} ms`);
console.log(`about ${Math.round(separately / together)} times faster`);
check("both wrote every row", db.prepare("SELECT count(*) AS n FROM events").get().n, 2000);
check("one transaction was at least ten times faster", separately / together > 10, true);
What node printed, when the build ran it
node example.mjs
1,000 inserts, each its own transaction: 607 ms 1,000 inserts in one transaction: 1.8 ms about 331 times faster ok both wrote every row ok one transaction was at least ten times faster
Every write outside a transaction is its own transaction, and committing one means waiting for the disk to confirm the data is really stored, so it will survive a power cut. A thousand separate inserts pay that wait a thousand times; wrapped in one transaction they pay it once. When you are importing or seeding many rows, this is the single biggest speed-up there is.
DatabaseSync is synchronous. Each call blocks the event loop until SQLite answers.
For SQLite that is usually microseconds, because the database is a file on the same machine, which
is why a synchronous API is a reasonable choice here. A slow query is a different matter; lesson 43
measures what blocking costs a server. Databases with their own server, such as PostgreSQL, are
reached over the network with an async client package instead, and the SQL is almost
the same.
new DatabaseSync(file)opens or creates a database;":memory:"makes a temporary one.preparea statement and pass values through placeholders, never inside the SQL text.runwrites,getreturns one row orundefined,allreturns an array,iteratestreams rows.- Constraints make the database refuse bad data. Use
STRICTtables for real types. - Store booleans as 1 and 0, and missing values as
null. - Wrap multi-step writes in a transaction: all or nothing, and much faster.
40 Injection, path traversal and the permission model
Most security holes in web applications are not clever. They come from one mistake repeated: treating text that came from outside — a form field, a URL, a JSON body — as if it were part of your program. Each section below shows a real attack succeeding against ordinary code, and then the fix. Every payload is harmless, and every "secret" is one the panel planted itself.
SQL injection
example.mjs
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT NOT NULL, item TEXT NOT NULL, card TEXT NOT NULL);
INSERT INTO orders (customer, item, card) VALUES
('ada', 'tea', '**** 4242'), ('alan', 'coffee', '**** 1881'), ('grace', 'cake', '**** 0007');
`);
// Unsafe: the customer's text is pasted into the SQL.
function ordersUnsafe(customer) {
return db.prepare(`SELECT customer, item, card FROM orders WHERE customer = '${customer}'`).all();
}
// Safe: the SQL is fixed, and the value travels separately.
function orders(customer) {
return db.prepare("SELECT customer, item, card FROM orders WHERE customer = ?").all(customer);
}
const show = (rows) => rows.map((r) => `${r.customer}: ${r.item}, ${r.card}`);
const attack = "nobody' OR '1'='1";
console.log("the SQL the unsafe version builds:");
console.log(` WHERE customer = '${attack}'`);
console.log("unsafe, for ada: ", show(ordersUnsafe("ada")));
console.log("unsafe, for the attacker:", show(ordersUnsafe(attack)));
let broke;
try { ordersUnsafe("o'brien"); } catch (err) { broke = err.message; }
console.log("unsafe, for o'brien: ", broke);
console.log("safe, for the attacker: ", show(orders(attack)));
console.log("safe, for o'brien: ", show(orders("o'brien")));
check("the attacker got every customer's orders", ordersUnsafe(attack).length, 3);
check("an apostrophe in a real name broke the unsafe query", typeof broke, "string");
check("with a placeholder the attack is just a name nobody has", orders(attack), []);
What node printed, when the build ran it
node example.mjs
the SQL the unsafe version builds: WHERE customer = 'nobody' OR '1'='1' unsafe, for ada: [ 'ada: tea, **** 4242' ] unsafe, for the attacker: [ 'ada: tea, **** 4242', 'alan: coffee, **** 1881', 'grace: cake, **** 0007' ] unsafe, for o'brien: near "brien": syntax error safe, for the attacker: [] safe, for o'brien: [] ok the attacker got every customer's orders ok an apostrophe in a real name broke the unsafe query ok with a placeholder the attack is just a name nobody has
The attacker typed a quote, which closed the string the programmer opened, and then
OR '1'='1', which is true for every row. The database did exactly what the SQL said and
returned everyone's orders. The same bug also breaks for an honest customer called O'Brien, whose
apostrophe ends the string early — which is often how injection holes are first noticed.
With a ? placeholder the SQL is fixed before any value is seen, and the value is sent
to the database separately, as data. There is no way for it to become SQL, whatever it contains. The
fix is never to escape quotes by hand: it is to never build SQL out of strings. The same rule holds
for every database and every library.
Path traversal
A static file server takes a path from the URL and reads that file from a folder. The panel's
server means to serve ./public; next to it is a .env with a database
password in it, which is exactly where real ones keep theirs (lesson 31).
static.mjs
import { createServer } from "node:http";
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
// Serve the files in ./public -- or so it intends.
const server = createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
try {
res.end(await readFile(join("public", pathname)));
} catch (err) {
res.writeHead(404);
res.end(`not found (${err.code})`);
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
// A visitor, and an attacker.
for (const path of ["/index.html", "/../.env", "/..%2f.env"]) {
const res = await fetch(base + path);
console.log(`${path.padEnd(12)} ${res.status} ${(await res.text()).trim()}`);
}
server.close();
public/index.html
<h1>Welcome</h1>
.env
DB_PASSWORD=pretend-password-123
The terminal, as the build ran it
node static.mjs /index.html 200 <h1>Welcome</h1> /../.env 404 not found (ENOENT) /..%2f.env 200 DB_PASSWORD=pretend-password-123 node --permission --allow-fs-read=./public/ --allow-net static.mjs (node:<pid>) ExperimentalWarning: The flag --allow-net is under experimental phase. (Use `node --trace-warnings ...` to show where the warning was created) /index.html 200 <h1>Welcome</h1> /../.env 404 not found (ENOENT) /..%2f.env 404 not found (ERR_ACCESS_DENIED)
The plain /../.env is stopped by accident: fetch, like a browser, tidies
.. out of a URL before sending it, so the server received /.env and found
nothing. Attackers do not use browsers. ..%2f is ../ with the slash
percent-encoded, which no URL parser tidies away; the server's own
decodeURIComponent turned it back into ../, join walked up out
of public, and the password came back with a 200.
The second run is the same code, run with Node's permission model:
--permission denies all file access, networking and child processes, and each
--allow- flag grants one thing back. This server was allowed to read
./public/ and to use the network, and nothing else, so the read of .env was
refused with ERR_ACCESS_DENIED by Node itself — before the operating system was even
asked. (The warning is Node's own: --allow-net is newer than the rest of the model and
is still marked experimental.) That is a second line of defence. The first is the code:
public/index.html
<h1>Welcome</h1>
.env
DB_PASSWORD=pretend-password-123
example.mjs
import { createServer } from "node:http";
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import { resolve, sep } from "node:path";
const ROOT = resolve("public");
// Turn a URL path into a file inside ROOT, or refuse.
function fileFor(urlPath) {
let decoded;
try {
decoded = decodeURIComponent(urlPath);
} catch {
return { status: 400 }; // "%zz" is not valid encoding
}
const file = resolve(ROOT, "." + decoded); // resolve does all the ".." walking
if (!file.startsWith(ROOT + sep)) return { status: 403 };
return { file };
}
const server = createServer(async (req, res) => {
const { file, status } = fileFor(new URL(req.url, "http://localhost").pathname);
if (!file) {
res.writeHead(status);
return res.end();
}
try {
res.end(await readFile(file));
} catch {
res.writeHead(404);
res.end();
}
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;
const statuses = [];
for (const path of ["/index.html", "/..%2f.env", "/%2e%2e%2f.env", "/css/..%2f..%2f.env", "/%zz"]) {
const res = await fetch(base + path);
statuses.push(res.status);
console.log(`${path.padEnd(20)} ${res.status} ${(await res.text()).trim()}`);
}
server.close();
check("the page is served, and every escape is refused", statuses, [200, 403, 403, 403, 400]);
What node printed, when the build ran it
node example.mjs
/index.html 200 <h1>Welcome</h1> /..%2f.env 403 /%2e%2e%2f.env 403 /css/..%2f..%2f.env 403 /%zz 400 ok the page is served, and every escape is refused
The fix does not try to spot dangerous patterns in the input, because there are always more
encodings than you thought of. It lets resolve work out where the path really leads, and
then checks the one thing that matters: is the result still inside the folder? Anything else is a
403. The sep matters too — without it, a folder called public-backup next to
public would pass a plain startsWith(ROOT). And the try around
decodeURIComponent is there because it throws on malformed input like
%zz, which, unhandled, is the crash from lesson 36.
Prototype pollution
example.mjs
// A JSON body, as an attacker would send it.
const body = JSON.parse('{ "theme": "light", "__proto__": { "isAdmin": true } }');
console.log("the parsed body's keys:", Object.keys(body));
// 1. Copied onto one object.
const todo = Object.assign({ id: 1 }, body);
console.log("todo.isAdmin:", todo.isAdmin);
// 2. Merged, with a hand-written deep merge.
function merge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] ??= {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({ theme: "dark" }, body);
const someoneElse = { name: "Grace" }; // created afterwards, never touched
console.log("someoneElse.isAdmin:", someoneElse.isAdmin);
console.log("[].isAdmin:", [].isAdmin);
check("Object.assign replaced that object's prototype", Object.getPrototypeOf(todo).isAdmin, true);
check("the merge made every object in the process an admin", someoneElse.isAdmin, true);
// The fix: never walk into keys that reach the prototype.
delete Object.prototype.isAdmin;
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (FORBIDDEN.has(key)) continue;
if (typeof source[key] === "object" && source[key] !== null) {
target[key] ??= {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
console.log("after safeMerge:", safeMerge({ theme: "dark" }, body), ({}).isAdmin);
check("safeMerge copied the theme and nothing else", ({}).isAdmin, undefined);
What node printed, when the build ran it
node example.mjs
the parsed body's keys: [ 'theme', '__proto__' ]
todo.isAdmin: true
someoneElse.isAdmin: true
[].isAdmin: true
ok Object.assign replaced that object's prototype
ok the merge made every object in the process an admin
after safeMerge: { theme: 'light' } undefined
ok safeMerge copied the theme and nothing else
JSON.parse makes "__proto__" an ordinary key, and it is harmless until
something copies it with plain assignment, where obj.__proto__ = value means "replace the
prototype" (lesson 17). Object.assign did that to one object, which now inherits
isAdmin. The deep merge did something far worse: it walked into
target.__proto__, which is Object.prototype, and set isAdmin
there, so every object in the whole process — every user, every request, every array — now answers
true. A check such as if (user.isAdmin) anywhere in the server now passes
for everyone.
Merge libraries have shipped this exact bug many times. The defences, strongest first: validate the
body and accept only the fields you expect, as lesson 37's checkTodo did (it refuses
__proto__ as an unknown field); use a Map for data keyed by user input
(lesson 15); and never let a merge or a path-setter walk into __proto__,
constructor or prototype.
The permission model
Every package you install runs with all of your permissions: it can read any file you can, open network connections and start programs. The panel's report uses a small dependency that has been compromised, as real packages occasionally are, and quietly tries to read an SSH key. The session plants a pretend key in the panel's throwaway home directory first, so there is something to steal.
report.mjs
import { readFileSync } from "node:fs";
import { total } from "./lib/pretty.mjs";
const lines = readFileSync("data/sales.csv", "utf8").trim().split("\n").slice(1);
console.log("total sales:", total(lines.map((line) => Number(line.split(",")[1]))));
lib/pretty.mjs
// A small dependency that adds up numbers -- and has been compromised.
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
export function total(numbers) {
try {
const key = readFileSync(`${homedir()}/.ssh/id_ed25519`, "utf8");
console.error(`[pretty] read your SSH key: "${key.trim()}"`);
} catch (err) {
console.error(`[pretty] could not read your SSH key: ${err.code}`);
}
return numbers.reduce((sum, n) => sum + n, 0).toFixed(2);
}
data/sales.csv
day,amount
mon,120.50
tue,98.25
wed,143.00
The terminal, as the build ran it
mkdir -p ~/.ssh && echo "PRETEND-PRIVATE-KEY" > ~/.ssh/id_ed25519 node report.mjs [pretty] read your SSH key: "PRETEND-PRIVATE-KEY" total sales: 361.75 node --permission report.mjs # exits 1 node:fs:697 return binding.open( ^ Error: Access to this API has been restricted. Use --allow-fs-read to manage permissions. at Object.openSync (node:fs:697:18) at Object.readFileSync (node:fs:543:35) at getSourceSync (node:internal/modules/esm/load:41:17) at defaultLoadSync (node:internal/modules/esm/load:156:34) at #loadAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:809:12) at #loadSync (node:internal/modules/esm/loader:841:53) at ModuleLoader.load (node:internal/modules/esm/loader:790:26) at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:501:31) at #getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36) at afterResolve (node:internal/modules/esm/loader:614:52) { code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', resource: '/app/lib/pretty.mjs' } Node.js v26.7.0 node --permission --allow-fs-read=./ report.mjs [pretty] could not read your SSH key: ERR_ACCESS_DENIED total sales: 361.75
Run normally, the dependency read the key and could have sent it anywhere. With
--permission alone, nothing may be read at all — not even the program's own
import of lib/pretty.mjs, so it died at once. Granting read access to the
project folder, ./, let the report run and still kept the dependency out of the home
directory. The error codes tell the story: ENOENT would have meant the operating system
looked and found nothing, while ERR_ACCESS_DENIED means Node refused to look.
| Flag | Grants |
|---|---|
--permission | nothing: turns the model on and denies everything below |
--allow-fs-read=./data/ | reading files under that path (repeat the flag for more) |
--allow-fs-write=./logs/ | writing files under that path |
--allow-net | listening and connecting (still experimental) |
--allow-child-process | starting other programs |
--allow-worker | worker threads (lesson 44) |
Finding the right set of flags for a real program by trial and error is tedious, so Node also has
--permission-audit, which checks every operation but, instead of refusing, publishes each
would-be denial on a diagnostics channel your code can listen to, and lets the program carry
on.
A seat belt, not a sandbox. Node's documentation is explicit that the permission
model protects trusted code from doing what it was not meant to, and that it is not a security
guarantee against code written to be malicious, which may find a way around it. It is well worth
using on a server, and it is not a reason to install packages you do not trust. For that, the
defences are fewer dependencies, a lock file, and --ignore-scripts (lesson 23).
Environment variables are not covered by it at all, which is one more reason to keep secrets few.
It also only covers what it covers, and that is worth testing rather than assuming. The program
below is allowed to read its own folder and nothing more, and tries to write outside it twice: once
with node:fs, and once by opening a SQLite database (lesson 39).
save.mjs
import { writeFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
const target = process.argv[2];
try {
writeFileSync(`${target}.txt`, "hello");
console.log("node:fs wrote", `${target}.txt`);
} catch (err) {
console.log("node:fs was refused:", err.code);
}
const db = new DatabaseSync(target);
db.exec("CREATE TABLE IF NOT EXISTS notes (text TEXT)");
db.close();
console.log("node:sqlite created", target);
The terminal, as the build ran it
node --permission --allow-fs-read=./ save.mjs ../outside.db node:fs was refused: ERR_ACCESS_DENIED node:sqlite created ../outside.db ls .. app outside.db
node:fs was refused. node:sqlite created a database outside the folder
anyway, because SQLite opens its files itself, through its own code rather than Node's, and in this
version of Node the permission model does not check it. Native add-ons could slip past it the same
way, which is why --permission refuses to load them at all unless
--allow-addons says otherwise. A seat belt with gaps is still worth wearing; just know
where the gaps are.
Passwords
example.mjs
import { scrypt, randomBytes, timingSafeEqual, createHash } from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(scrypt);
async function hashPassword(password) {
const salt = randomBytes(16); // different for every user
const key = await scryptAsync(password, salt, 64);
return `scrypt:${salt.toString("base64")}:${key.toString("base64")}`;
}
async function verifyPassword(password, stored) {
const [, salt, key] = stored.split(":");
const attempt = await scryptAsync(password, Buffer.from(salt, "base64"), 64);
return timingSafeEqual(attempt, Buffer.from(key, "base64"));
}
let started = performance.now();
const stored = await hashPassword("correct horse battery staple");
const scryptMs = performance.now() - started;
console.log("stored:", stored);
console.log(`one scrypt hash took ${scryptMs.toFixed(0)} ms`);
const again = await hashPassword("correct horse battery staple");
const right = await verifyPassword("correct horse battery staple", stored);
const wrong = await verifyPassword("Correct horse battery staple", stored);
console.log("same password, same hash?", again === stored);
console.log("right password:", right, " wrong one:", wrong);
started = performance.now();
for (let i = 0; i < 1000; i++) createHash("sha256").update(`guess ${i}`).digest();
const shaMs = (performance.now() - started) / 1000;
console.log(`one SHA-256 hash took ${shaMs.toFixed(4)} ms`);
check("the salt makes the same password store differently", again === stored, false);
check("verify accepts the right password and rejects a near miss", [right, wrong], [true, false]);
check("scrypt is over a thousand times slower than SHA-256, on purpose", scryptMs / shaMs > 1000, true);
What node printed, when the build ran it
node example.mjs
stored: scrypt:htcLSFloLkmRnh/DqYq8Aw==:p74A/Tau4E4h/btylDR1BPWwcsiEIJ+W8Hq7SgiJVG1Y4C0l/U599QiQ4rtlVp3WByiOXMU3dWYCNDgoamIpMw== one scrypt hash took 53 ms same password, same hash? false right password: true wrong one: false one SHA-256 hash took 0.0056 ms ok the salt makes the same password store differently ok verify accepts the right password and rejects a near miss ok scrypt is over a thousand times slower than SHA-256, on purpose
Never store a password, and never store a fast hash of one. If the database leaks — assume it
will — an attacker can try billions of guesses against a SHA-256 hash, which is built to be fast.
scrypt, built into node:crypto, is built to be slow and to need memory, so
each guess costs tens of milliseconds instead of microseconds. The random salt, stored beside
the hash, means two users with the same password get different hashes, so one cracked guess does not
unlock both. timingSafeEqual compares in the same time whether the first byte differs or
the last, so the response time cannot leak how close a guess was.
- Never build SQL from strings. Use placeholders, always.
- For a path from a URL: decode inside a
try,resolveit, and check it is still inside the folder. - Never copy or merge a body into your objects unchecked. Accept named fields only, and skip
__proto__,constructorandprototype. --permissionwith--allow-*flags limits what a process can touch. It is a seat belt, not a sandbox.- Store passwords with a slow, salted hash such as
scrypt, and compare withtimingSafeEqual.