Skip to content

Commit e31293b

Browse files
fix(nostr): NIP-98 payload hash verifies raw request bytes, not a re-serialization — closes JavaScriptSolidServer#565 (JavaScriptSolidServer#573)
* fix(nostr): NIP-98 payload hash verifies raw request bytes, not a re-serialization (JavaScriptSolidServer#565) src/auth/nostr.js hashed JSON.stringify(request.body) — the parsed- then-recompacted body — to check the NIP-98 `payload` tag. NIP-98 defines that tag as sha256 over the EXACT body bytes on the wire, so any client sending pretty-printed (or differently-escaped / different key-order) JSON 401'd with 'Payload hash mismatch' despite a valid signature, URL, method, and timestamp. It silently forced every NIP-98 client to send minified JSON in Node's exact serialization — not a real spec requirement, and the bug only bit Nostr auth (DPoP has no payload check), making valid signatures look broken. Root cause: by the time the auth check runs, Fastify's default application/json parser has already turned the body into an object — the raw bytes are gone, so the re-serialization was the best the code could do, and it only matched by coincidence when the client minified. Fix (two halves): - server.js overrides the application/json content-type parser to capture the raw bytes as request.rawBody before parsing. It otherwise mirrors Fastify 4's defaultJsonParser exactly — empty body → 400, secure-json-parse (the SAME prototype-pollution protection JSS gets today; not plain JSON.parse), 400 on malformed — so no other request path changes. secure-json-parse promoted to a direct dependency for this. - nostr.js hashes request.rawBody when present, never a re-serialization. Other content types are unaffected: they stay Buffers via the '*' parser and already hashed raw bytes correctly; only application/json (parsed to an object) was broken. Tests (test/nip98-payload-hash.test.js, 6): - parser: pretty JSON accepted, malformed → 400, __proto__ → 400 + Object.prototype not polluted (proves secure parsing preserved) - payload hash: a pretty body with rawBody passes AND resolves to the did:nostr identity; the same body WITHOUT rawBody still mismatches (control — proves rawBody is the fix); a compact body still verifies (no regression). Full suite: 968/968 (the global JSON-parser override regressed nothing). Closes JavaScriptSolidServer#565. * review fix (JavaScriptSolidServer#573): hash Buffer bodies directly, not via a lossy toString() Copilot caught a real correctness bug in the pre-existing Buffer branch: `request.body.toString()` UTF-8-decodes the bytes, which mangles binary / non-UTF-8 bodies (an image PUT, etc.) — so the server's hash wouldn't match the raw-byte sha256 a NIP-98 client signed. crypto.update() accepts a Buffer directly, so we now pass the Buffer through unconverted and only ever hash native forms (string → UTF-8, Buffer → raw bytes). New test: a deliberately non-UTF-8 Buffer body (with a sanity check that it's genuinely lossy under a UTF-8 round-trip) verifies against its raw-byte payload hash — would have failed under the old .toString() path. 7/7 in the file; full suite green. * review pass-2 fix (JavaScriptSolidServer#573): hash-check payload for falsy-parsed bodies too Copilot pass-2 (separate from the Buffer fix): the `if (payloadTag && request.body)` guard skipped payload validation when a JSON body parsed to a falsy value (null/false/0/""), so a client could sign a payload tag, send a falsy body, and the integrity check silently didn't run. Guard on `request.body !== undefined` instead — Fastify leaves body undefined only when no body was sent — so falsy-but-present bodies are still verified while genuinely bodyless requests stay skipped. New test: a `false` body with a WRONG payload tag is now rejected (was skipped), and with the correct tag passes. 8/8 in file; full suite green. * review pass-3 fix (JavaScriptSolidServer#573): empty-JSON error carries FST_ERR_CTP_EMPTY_JSON_BODY code Copilot pass-3: the custom parser's empty-body 400 was a plain Error with no err.code, but JSS's error handler emits code: err.code, so the response shape drifted from Fastify's default (which uses FST_ERR_CTP_EMPTY_JSON_BODY) — undercutting the 'mirrors the default exactly' claim. Set err.code = 'FST_ERR_CTP_EMPTY_JSON_BODY' with the same message/status. The malformed-JSON path already matched (Fastify sets statusCode on the raw parser error without an FST code; so do we) — documented that in a comment so it doesn't look like an oversight. New test: an empty application/json body returns 400 with code === 'FST_ERR_CTP_EMPTY_JSON_BODY'. 9/9 in file; full suite green.
1 parent d7617cc commit e31293b

5 files changed

Lines changed: 254 additions & 9 deletions

File tree

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"mime-types": "^2.1.35",
4040
"n3": "^1.26.0",
4141
"oidc-provider": "^9.6.0",
42+
"secure-json-parse": "^2.7.0",
4243
"sql.js": "^1.13.0",
4344
"tar-stream": "^3.2.0"
4445
},

src/auth/nostr.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,41 @@ export async function verifyNostrAuth(request) {
243243

244244
// Validate payload hash if present and request has body
245245
const payloadTag = getTagValue(event, 'payload');
246-
if (payloadTag && request.body) {
247-
let bodyString;
248-
if (typeof request.body === 'string') {
249-
bodyString = request.body;
246+
// Validate whenever a payload tag is present AND a body was provided —
247+
// keyed on `!== undefined`, not truthiness, so a JSON body that parses
248+
// to a falsy value (`null`, `false`, `0`, `""`) is still hash-checked
249+
// rather than silently skipping the integrity guard (Copilot review on
250+
// #573). Fastify leaves request.body `undefined` when no body was sent.
251+
if (payloadTag && request.body !== undefined) {
252+
// Hash the EXACT bytes the client signed. NIP-98's `payload` tag is
253+
// sha256(request body) over the wire bytes. crypto.update() accepts a
254+
// string (encoded UTF-8) or a Buffer (raw bytes), so we pass each
255+
// through in its native form — never a lossy conversion.
256+
let bodyData;
257+
if (typeof request.rawBody === 'string') {
258+
// application/json raw wire string captured by the parser (#565),
259+
// because by this point request.body is already a parsed object and
260+
// the original bytes are gone. Re-serializing the object (the old
261+
// fallback) only matched when the client happened to send minified
262+
// JSON in Node's exact key order — pretty-printed or differently-
263+
// escaped bodies 401'd despite a valid signature. (JSON is UTF-8 by
264+
// spec, so the captured string round-trips losslessly.)
265+
bodyData = request.rawBody;
266+
} else if (typeof request.body === 'string') {
267+
bodyData = request.body;
250268
} else if (Buffer.isBuffer(request.body)) {
251-
bodyString = request.body.toString();
269+
// Hash the Buffer DIRECTLY. A .toString() round-trip UTF-8-mangles
270+
// binary / non-UTF-8 bodies (e.g. an image PUT) and would cause
271+
// false mismatches against the raw-byte hash the client signed.
272+
bodyData = request.body;
252273
} else {
253-
bodyString = JSON.stringify(request.body);
274+
// No raw bytes captured (shouldn't happen for HTTP requests:
275+
// application/json sets rawBody, other types stay a Buffer). Keep a
276+
// deterministic fallback rather than throwing.
277+
bodyData = JSON.stringify(request.body);
254278
}
255279

256-
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
280+
const expectedHash = crypto.createHash('sha256').update(bodyData).digest('hex');
257281
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
258282
return { webId: null, error: 'Payload hash mismatch' };
259283
}

src/server.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Fastify from 'fastify';
2+
import sjson from 'secure-json-parse';
23
import rateLimit from '@fastify/rate-limit';
34
import { readFile } from 'fs/promises';
45
import { readFileSync } from 'fs';
@@ -269,7 +270,43 @@ export function createServer(options = {}) {
269270
done(null, body);
270271
});
271272

273+
// Override the default application/json parser so the NIP-98 payload-hash
274+
// check (src/auth/nostr.js) can verify against the EXACT bytes the client
275+
// signed, not a re-serialization of the parsed object (#565). The default
276+
// parser discards the raw bytes once it produces an object, so capturing
277+
// req.rawBody here is the only point they still exist. Behaviour otherwise
278+
// mirrors Fastify 4's defaultJsonParser exactly — empty body → 400,
279+
// secure-json-parse (same prototype-pollution protection JSS gets today),
280+
// 400 on malformed — so no other request path changes. (Must
281+
// removeContentTypeParser first: Fastify throws on a duplicate type.)
282+
fastify.removeContentTypeParser('application/json');
283+
fastify.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => {
284+
req.rawBody = body;
285+
if (body === '' || body == null) {
286+
// Match Fastify's FST_ERR_CTP_EMPTY_JSON_BODY exactly (code +
287+
// message + status), so the error-response shape — which surfaces
288+
// err.code — is identical to the default parser's.
289+
const err = new Error("Body cannot be empty when content-type is set to 'application/json'");
290+
err.code = 'FST_ERR_CTP_EMPTY_JSON_BODY';
291+
err.statusCode = 400;
292+
return done(err, undefined);
293+
}
294+
let json;
295+
try {
296+
// The malformed-JSON path already mirrors Fastify's default: it
297+
// sets statusCode 400 on the raw parser error without adding an FST
298+
// code (the default does the same), so no code is set here.
299+
json = sjson.parse(body);
300+
} catch (err) {
301+
err.statusCode = 400;
302+
return done(err, undefined);
303+
}
304+
done(null, json);
305+
});
306+
272307
// Attach server config to requests
308+
// Raw request body for the application/json parser to stash (#565).
309+
fastify.decorateRequest('rawBody', null);
273310
fastify.decorateRequest('connegEnabled', null);
274311
fastify.decorateRequest('notificationsEnabled', null);
275312
fastify.decorateRequest('idpEnabled', null);

test/nip98-payload-hash.test.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* NIP-98 payload-hash verifies the RAW request bytes, not a re-serialization
3+
* of the parsed body (#565).
4+
*
5+
* The bug: src/auth/nostr.js hashed `JSON.stringify(request.body)` — the
6+
* parsed-then-recompacted body — so a NIP-98 client that sent pretty-printed
7+
* (or otherwise non-minified) JSON 401'd with "Payload hash mismatch" even
8+
* though signature/URL/method/timestamp were all valid. NIP-98's `payload`
9+
* tag is sha256 over the exact body bytes on the wire.
10+
*
11+
* The fix has two halves, tested here:
12+
* 1. server.js captures the raw application/json bytes as request.rawBody
13+
* (the default parser discards them once it produces an object), while
14+
* otherwise mirroring Fastify's default parser — empty → 400,
15+
* secure-json-parse (prototype-pollution safe), 400 on malformed.
16+
* 2. nostr.js hashes request.rawBody when present, never a re-serialization.
17+
*/
18+
19+
import { describe, it, before, after } from 'node:test';
20+
import assert from 'node:assert';
21+
import { createServer } from '../src/server.js';
22+
import { verifyNostrAuth } from '../src/auth/nostr.js';
23+
import { generateSecretKey, getPublicKey, nip98Token } from '../src/nostr/event.js';
24+
import fs from 'fs-extra';
25+
26+
const DATA_DIR = './test-data-nip98-payload';
27+
28+
describe('application/json parser: rawBody capture + preserved semantics (#565)', () => {
29+
let server;
30+
let originalDataRoot;
31+
32+
before(async () => {
33+
originalDataRoot = process.env.DATA_ROOT;
34+
await fs.remove(DATA_DIR);
35+
await fs.ensureDir(DATA_DIR);
36+
server = createServer({
37+
logger: false,
38+
root: DATA_DIR,
39+
public: true, // open writes so we exercise the parser without auth
40+
forceCloseConnections: true,
41+
});
42+
await server.ready();
43+
});
44+
45+
after(async () => {
46+
if (server) await server.close();
47+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
48+
else process.env.DATA_ROOT = originalDataRoot;
49+
await fs.remove(DATA_DIR);
50+
});
51+
52+
it('accepts a valid (pretty-printed) JSON body — parser stays functional', async () => {
53+
const res = await server.inject({
54+
method: 'PUT',
55+
url: '/public/pretty.json',
56+
headers: { 'content-type': 'application/json' },
57+
payload: JSON.stringify({ a: 1, b: 'two' }, null, 2),
58+
});
59+
assert.ok(res.statusCode === 200 || res.statusCode === 201 || res.statusCode === 204,
60+
`pretty JSON PUT should succeed, got ${res.statusCode}`);
61+
});
62+
63+
it('rejects malformed JSON with 400 (mirrors Fastify default, not 500)', async () => {
64+
const res = await server.inject({
65+
method: 'PUT',
66+
url: '/public/bad.json',
67+
headers: { 'content-type': 'application/json' },
68+
payload: '{ not valid json',
69+
});
70+
assert.strictEqual(res.statusCode, 400);
71+
});
72+
73+
it('empty JSON body errors with Fastify\'s FST_ERR_CTP_EMPTY_JSON_BODY code (response-shape fidelity)', async () => {
74+
const res = await server.inject({
75+
method: 'PUT',
76+
url: '/public/empty.json',
77+
headers: { 'content-type': 'application/json' },
78+
payload: '',
79+
});
80+
assert.strictEqual(res.statusCode, 400);
81+
assert.strictEqual(res.json().code, 'FST_ERR_CTP_EMPTY_JSON_BODY',
82+
'empty-body error must carry Fastify\'s standard code so the response shape matches the default parser');
83+
});
84+
85+
it('blocks __proto__ prototype pollution (secure-json-parse preserved)', async () => {
86+
const res = await server.inject({
87+
method: 'PUT',
88+
url: '/public/proto.json',
89+
headers: { 'content-type': 'application/json' },
90+
payload: '{ "__proto__": { "polluted": true } }',
91+
});
92+
// secure-json-parse throws on __proto__ → our parser surfaces 400.
93+
assert.strictEqual(res.statusCode, 400, 'a __proto__ payload must be rejected');
94+
assert.strictEqual({}.polluted, undefined, 'Object.prototype must not be polluted');
95+
});
96+
});
97+
98+
describe('NIP-98 payload hash uses the raw request bytes (#565)', () => {
99+
const sk = generateSecretKey();
100+
const pk = getPublicKey(sk);
101+
102+
// A pretty-printed body whose minified re-serialization differs byte-for-byte.
103+
const entry = { name: 'alice', nested: { x: 1, y: 2 }, tags: ['a', 'b'] };
104+
const prettyBody = JSON.stringify(entry, null, 2);
105+
const compactBody = JSON.stringify(entry);
106+
const parsed = JSON.parse(prettyBody);
107+
108+
const url = 'http://localhost:9999/pool/entries/' + pk + '.json';
109+
110+
// Build a mock Fastify request whose URL/host/method match what was signed,
111+
// so verification reaches the payload-hash check.
112+
function mockRequest(token, { rawBody, body }) {
113+
return {
114+
method: 'PUT',
115+
url: '/pool/entries/' + pk + '.json',
116+
protocol: 'http',
117+
hostname: 'localhost:9999',
118+
headers: { host: 'localhost:9999', authorization: `Nostr ${token}` },
119+
rawBody,
120+
body,
121+
};
122+
}
123+
124+
it('a pretty-printed body with rawBody captured passes the payload check', async () => {
125+
// Client signed sha256(prettyBody) — exactly the bytes it sent.
126+
const token = nip98Token(url, 'PUT', sk, prettyBody);
127+
const result = await verifyNostrAuth(mockRequest(token, { rawBody: prettyBody, body: parsed }));
128+
assert.notStrictEqual(result.error, 'Payload hash mismatch',
129+
`pretty body should pass the payload check; got error: ${result.error}`);
130+
assert.ok(result.webId && result.webId.startsWith('did:nostr:'),
131+
`should resolve to the did:nostr identity; got ${JSON.stringify(result)}`);
132+
});
133+
134+
it('control: the same body WITHOUT rawBody (object only) still mismatches — proves rawBody is the fix', async () => {
135+
const token = nip98Token(url, 'PUT', sk, prettyBody);
136+
// No rawBody → the old code path hashes JSON.stringify(parsed) = compact.
137+
const result = await verifyNostrAuth(mockRequest(token, { rawBody: undefined, body: parsed }));
138+
assert.strictEqual(result.error, 'Payload hash mismatch',
139+
'without the captured raw bytes, a pretty body must still fail (the bug)');
140+
});
141+
142+
it('a compact body still verifies (no regression for already-working clients)', async () => {
143+
const token = nip98Token(url, 'PUT', sk, compactBody);
144+
const result = await verifyNostrAuth(mockRequest(token, { rawBody: compactBody, body: parsed }));
145+
assert.notStrictEqual(result.error, 'Payload hash mismatch',
146+
`compact body should still pass; got error: ${result.error}`);
147+
});
148+
149+
it('a falsy JSON body (false) is still hash-checked, not skipped (Copilot review on #573)', async () => {
150+
// The body literally parses to `false`. A WRONG payload tag must still
151+
// be caught — under the old `&& request.body` truthiness guard this
152+
// validation was skipped entirely for falsy bodies.
153+
const falsyRaw = 'false';
154+
const wrongToken = nip98Token(url, 'PUT', sk, 'a-different-body');
155+
const mismatch = await verifyNostrAuth(mockRequest(wrongToken, { rawBody: falsyRaw, body: false }));
156+
assert.strictEqual(mismatch.error, 'Payload hash mismatch',
157+
'a falsy body with a wrong payload tag must be rejected, not skipped');
158+
159+
// And the matching payload for the same falsy body passes.
160+
const goodToken = nip98Token(url, 'PUT', sk, falsyRaw);
161+
const ok = await verifyNostrAuth(mockRequest(goodToken, { rawBody: falsyRaw, body: false }));
162+
assert.notStrictEqual(ok.error, 'Payload hash mismatch',
163+
`a falsy body with the correct payload should pass; got ${ok.error}`);
164+
});
165+
166+
it('a binary / non-UTF-8 Buffer body hashes the raw bytes (Copilot review on #573)', async () => {
167+
// Bytes that are NOT valid UTF-8 — a .toString() round-trip would
168+
// mangle them (replacement chars) and break the hash. The client
169+
// signs sha256 over the raw bytes; the server must do the same.
170+
const binary = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0xc3, 0x28]);
171+
// Sanity: this fixture genuinely changes bytes under a UTF-8 string
172+
// round-trip, so the old `.toString()` path WOULD have mismatched.
173+
assert.ok(!Buffer.from(binary.toString('utf8'), 'utf8').equals(binary),
174+
'fixture must be lossy under a UTF-8 round-trip');
175+
const token = nip98Token(url, 'PUT', sk, binary);
176+
// Non-JSON body → no rawBody; request.body is the raw Buffer (the `*`
177+
// content-type parser keeps it as a Buffer).
178+
const result = await verifyNostrAuth(mockRequest(token, { rawBody: undefined, body: binary }));
179+
assert.notStrictEqual(result.error, 'Payload hash mismatch',
180+
`binary body must hash raw bytes, not a UTF-8 round-trip; got error: ${result.error}`);
181+
});
182+
});

0 commit comments

Comments
 (0)