Skip to content

Commit 56fc537

Browse files
committed
test: page-side body-hash + content-script message whitelist
body-hash.test.js (11): exercise the real bodyToHashHex inside the nip98-interceptor IIFE by running it in a vm sandbox with a minimal page (window/fetch/XHR) and capturing the bodyHash it emits on podkey-nip98-request. Correct sha-256 hex for string, URLSearchParams, Blob, ArrayBuffer and typed-array bodies (respecting view offset); "" for no/empty body; "" for FormData (no canonical NIP-98 multipart hash). A page-set Authorization suppresses NIP-98 injection; absent auth emits a request. content-whitelist.test.js (9): injected.js forwards only the four NIP-07 types, rejects anything else (incl. CREATE_NIP98_AUTH_HEADER, which has its own channel) with "Unknown request type" and never calls sendMessage for it; forwards only safe fields per type, always overriding origin with the real page origin (drops attacker-supplied privateKey/peer/extra) and coerces nip44 fields to strings. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 677b9df commit 56fc537

2 files changed

Lines changed: 294 additions & 0 deletions

File tree

test/body-hash.test.js

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Tests for bodyToHashHex — the page-side NIP-98 `payload` body hashing in
3+
* nip98-interceptor.js. The raw request body never crosses to the background
4+
* service worker; the page computes its SHA-256 here (the only context where
5+
* FormData / URLSearchParams / Blob survive intact) and ships only the digest.
6+
*
7+
* bodyToHashHex is a private function inside the interceptor's page-context
8+
* IIFE (no ESM export — classic scripts can't import). To exercise the real
9+
* source it is loaded and run inside a vm sandbox with a minimal page (window,
10+
* fetch, XHR), and the bodyHash the interceptor emits on the
11+
* podkey-nip98-request event is captured. This drives the genuine code path
12+
* (window.fetch wrapper -> getNip98AuthHeader -> bodyToHashHex) rather than a
13+
* reimplementation.
14+
*/
15+
16+
import { describe, it, before } from 'node:test';
17+
import assert from 'node:assert/strict';
18+
import { readFileSync } from 'node:fs';
19+
import { createHash, webcrypto } from 'node:crypto';
20+
import vm from 'node:vm';
21+
22+
const sha256hex = (input) => createHash('sha256').update(input).digest('hex');
23+
24+
/** Build a fresh sandboxed page with the interceptor installed. */
25+
function makeInterceptedPage () {
26+
const src = readFileSync(new URL('../src/nip98-interceptor.js', import.meta.url), 'utf8');
27+
const bus = new EventTarget();
28+
const win = {
29+
__podkey_nip98_intercepted: false,
30+
addEventListener: (...a) => bus.addEventListener(...a),
31+
removeEventListener: (...a) => bus.removeEventListener(...a),
32+
dispatchEvent: (e) => bus.dispatchEvent(e),
33+
// originalFetch: a stub the wrapper calls after deciding on auth.
34+
fetch: async () => ({ status: 200, redirected: false, url: '' })
35+
};
36+
37+
// Echo every nip98 request back immediately so the fetch wrapper resolves,
38+
// recording the detail (which carries bodyHash) for assertions.
39+
const requests = [];
40+
bus.addEventListener('podkey-nip98-request', (e) => {
41+
requests.push(e.detail);
42+
bus.dispatchEvent(new CustomEvent('podkey-nip98-response', { detail: { id: e.detail.id, result: null } }));
43+
});
44+
45+
// Minimal XHR so the interceptor can patch its prototype without throwing.
46+
function XHR () {}
47+
XHR.prototype = { open () {}, send () {}, setRequestHeader () {} };
48+
49+
const sandbox = {
50+
window: win,
51+
XMLHttpRequest: XHR,
52+
crypto: webcrypto,
53+
CustomEvent, Headers, Request, TextEncoder,
54+
Blob, URLSearchParams, ArrayBuffer, Uint8Array, Uint16Array, Array, Object,
55+
console, setTimeout, clearTimeout
56+
};
57+
vm.createContext(sandbox);
58+
vm.runInContext(src, sandbox);
59+
60+
return {
61+
/** Issue a fetch through the wrapped fetch and return the captured bodyHash. */
62+
async fetchAndGetHash (init) {
63+
requests.length = 0;
64+
await win.fetch('https://pod.test/r', init);
65+
assert.equal(requests.length, 1, 'interceptor should emit exactly one nip98 request');
66+
return requests[0].bodyHash;
67+
},
68+
/** Issue a fetch and return whether any nip98 request was emitted. */
69+
async fetchEmitsRequest (input, init) {
70+
requests.length = 0;
71+
await win.fetch(input, init);
72+
return requests.length > 0;
73+
}
74+
};
75+
}
76+
77+
describe('bodyToHashHex (NIP-98 page-side body hashing)', () => {
78+
let page;
79+
before(() => { page = makeInterceptedPage(); });
80+
81+
it('hashes a string body to its sha-256 hex', async () => {
82+
const body = '{"hello":"world"}';
83+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body }), sha256hex(body));
84+
});
85+
86+
it('hashes a URLSearchParams body via its serialized form', async () => {
87+
const params = new URLSearchParams({ a: '1', b: 'two' });
88+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: params }), sha256hex(params.toString()));
89+
});
90+
91+
it('hashes a Blob body by its bytes', async () => {
92+
const text = 'blob-bytes-😀';
93+
const blob = new Blob([text]);
94+
const expected = sha256hex(Buffer.from(text, 'utf8'));
95+
assert.equal(await page.fetchAndGetHash({ method: 'PUT', body: blob }), expected);
96+
});
97+
98+
it('hashes an ArrayBuffer body', async () => {
99+
const bytes = new Uint8Array([1, 2, 3, 4, 250, 255]);
100+
const expected = sha256hex(Buffer.from(bytes));
101+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes.buffer }), expected);
102+
});
103+
104+
it('hashes a typed-array (Uint8Array) body', async () => {
105+
const bytes = new Uint8Array([10, 20, 30]);
106+
const expected = sha256hex(Buffer.from(bytes));
107+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: bytes }), expected);
108+
});
109+
110+
it('respects a typed-array view offset (does not hash the whole buffer)', async () => {
111+
const full = new Uint8Array([0, 0, 1, 2, 3, 0]);
112+
const view = full.subarray(2, 5); // [1,2,3]
113+
const expected = sha256hex(Buffer.from([1, 2, 3]));
114+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: view }), expected);
115+
});
116+
117+
it('returns "" for no body (GET)', async () => {
118+
assert.equal(await page.fetchAndGetHash({ method: 'GET' }), '');
119+
});
120+
121+
it('returns "" for an empty-string body', async () => {
122+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: '' }), '');
123+
});
124+
125+
it('returns "" for a FormData body (no canonical NIP-98 multipart hash)', async () => {
126+
// Documented decision: NIP-98 defines no canonical hash for multipart bodies,
127+
// so FormData is intentionally not hashed (no payload tag is added). A
128+
// FormData instance matches none of bodyToHashHex's recognised body types
129+
// (string / URLSearchParams / Blob / ArrayBuffer / typed-array) and hits the
130+
// `return ''` fallthrough.
131+
const fd = new FormData();
132+
fd.append('field', 'value');
133+
assert.equal(await page.fetchAndGetHash({ method: 'POST', body: fd }), '');
134+
});
135+
});
136+
137+
describe('interceptor honors a page-set Authorization (no NIP-98 injection)', () => {
138+
let page;
139+
before(() => { page = makeInterceptedPage(); });
140+
141+
it('does not emit a nip98 request when init.headers already has Authorization', async () => {
142+
const emitted = await page.fetchEmitsRequest('https://pod.test/r', {
143+
method: 'GET', headers: { Authorization: 'DPoP page-token' }
144+
});
145+
assert.equal(emitted, false, 'page-set DPoP must not be overwritten by NIP-98');
146+
});
147+
148+
it('emits a nip98 request when no Authorization is present', async () => {
149+
const emitted = await page.fetchEmitsRequest('https://pod.test/r', { method: 'GET' });
150+
assert.equal(emitted, true);
151+
});
152+
});

test/content-whitelist.test.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Tests for the content-script message-type whitelist (injected.js,
3+
* security/whitelist-message-types #19).
4+
*
5+
* The content script bridges page CustomEvents to chrome.runtime. It must:
6+
* - forward ONLY the four NIP-07 message types, rejecting anything else with
7+
* "Unknown request type" and never calling sendMessage for it
8+
* - forward ONLY known-safe fields per type (no arbitrary page-supplied props
9+
* reach the background), always appending the real page origin
10+
*
11+
* injected.js runs at import time against the DOM and chrome.runtime, so the
12+
* test mounts minimal window/document/chrome and then drives it via the
13+
* podkey-request / podkey-response CustomEvents the provider uses.
14+
*/
15+
16+
import { describe, it, before, beforeEach } from 'node:test';
17+
import assert from 'node:assert/strict';
18+
19+
const ORIGIN = 'https://app.test';
20+
const bus = new EventTarget();
21+
const forwarded = [];
22+
23+
before(async () => {
24+
global.window = {
25+
addEventListener: (...a) => bus.addEventListener(...a),
26+
removeEventListener: (...a) => bus.removeEventListener(...a),
27+
dispatchEvent: (e) => bus.dispatchEvent(e),
28+
location: { origin: ORIGIN }
29+
};
30+
global.document = {
31+
createElement: () => ({ set src (_v) {}, set onload (_v) {}, set onerror (_v) {}, remove () {} }),
32+
head: { appendChild () {} },
33+
documentElement: { appendChild () {} }
34+
};
35+
global.chrome = {
36+
runtime: {
37+
getURL: (p) => 'chrome-extension://test/' + p,
38+
sendMessage: async (message) => {
39+
forwarded.push(message);
40+
if (message.type === 'GET_PUBLIC_KEY') return 'a'.repeat(64);
41+
if (message.type === 'SIGN_EVENT') return { ...message.event, id: 'i'.repeat(64), sig: 's'.repeat(128) };
42+
return 'OK';
43+
},
44+
lastError: null
45+
}
46+
};
47+
await import('../src/injected.js');
48+
});
49+
50+
beforeEach(() => { forwarded.length = 0; });
51+
52+
let seq = 0;
53+
/** Dispatch a podkey-request and resolve with the matching podkey-response. */
54+
function request (detail) {
55+
const id = `req-${++seq}`;
56+
return new Promise((resolve) => {
57+
const handler = (e) => {
58+
if (e.detail.id !== id) return;
59+
bus.removeEventListener('podkey-response', handler);
60+
resolve(e.detail);
61+
};
62+
bus.addEventListener('podkey-response', handler);
63+
bus.dispatchEvent(new CustomEvent('podkey-request', { detail: { id, ...detail } }));
64+
});
65+
}
66+
67+
describe('content-script whitelist', () => {
68+
it('forwards GET_PUBLIC_KEY and returns the result', async () => {
69+
const res = await request({ type: 'GET_PUBLIC_KEY' });
70+
assert.equal(res.result, 'a'.repeat(64));
71+
assert.equal(forwarded.length, 1);
72+
assert.equal(forwarded[0].type, 'GET_PUBLIC_KEY');
73+
});
74+
75+
it('forwards SIGN_EVENT, NIP44_ENCRYPT and NIP44_DECRYPT', async () => {
76+
await request({ type: 'SIGN_EVENT', event: { kind: 1, created_at: 1, tags: [], content: 'x' } });
77+
await request({ type: 'NIP44_ENCRYPT', pubkey: 'f'.repeat(64), plaintext: 'hi' });
78+
await request({ type: 'NIP44_DECRYPT', pubkey: 'f'.repeat(64), ciphertext: 'BASE64' });
79+
assert.deepEqual(forwarded.map((m) => m.type), ['SIGN_EVENT', 'NIP44_ENCRYPT', 'NIP44_DECRYPT']);
80+
});
81+
82+
it('rejects an unknown type and never forwards it', async () => {
83+
const res = await request({ type: 'EXPORT_PRIVATE_KEY' });
84+
assert.deepEqual(res, { id: res.id, error: 'Unknown request type' });
85+
assert.equal(forwarded.length, 0, 'an unknown type must not reach the background');
86+
});
87+
88+
it('rejects NIP-98 header type on the NIP-07 channel (separate channel only)', async () => {
89+
// CREATE_NIP98_AUTH_HEADER is intentionally not in the NIP-07 whitelist;
90+
// it has its own podkey-nip98-request channel.
91+
const res = await request({ type: 'CREATE_NIP98_AUTH_HEADER', url: 'https://x/', method: 'GET' });
92+
assert.equal(res.error, 'Unknown request type');
93+
assert.equal(forwarded.length, 0);
94+
});
95+
96+
it('always appends the page origin to forwarded messages', async () => {
97+
await request({ type: 'GET_PUBLIC_KEY' });
98+
assert.equal(forwarded[0].origin, ORIGIN);
99+
});
100+
});
101+
102+
describe('content-script field stripping (only safe fields forwarded)', () => {
103+
it('SIGN_EVENT forwards only { event, type, origin } — drops injected extras', async () => {
104+
await request({
105+
type: 'SIGN_EVENT',
106+
event: { kind: 1, created_at: 1, tags: [], content: 'x' },
107+
privateKey: 'attacker-supplied', // must NOT be forwarded
108+
origin: 'https://spoofed.evil' // page-supplied origin must be overridden
109+
});
110+
const msg = forwarded[0];
111+
assert.deepEqual(Object.keys(msg).sort(), ['event', 'origin', 'type']);
112+
assert.equal(msg.origin, ORIGIN, 'origin must be the real page origin, not a spoofed one');
113+
assert.equal('privateKey' in msg, false);
114+
});
115+
116+
it('NIP44_ENCRYPT forwards only pubkey + plaintext (field is "pubkey", not "peer")', async () => {
117+
await request({
118+
type: 'NIP44_ENCRYPT', pubkey: 'f'.repeat(64), plaintext: 'secret',
119+
peer: 'should-be-dropped', extra: 'nope'
120+
});
121+
const msg = forwarded[0];
122+
assert.deepEqual(Object.keys(msg).sort(), ['origin', 'plaintext', 'pubkey', 'type']);
123+
assert.equal(msg.pubkey, 'f'.repeat(64));
124+
assert.equal(msg.plaintext, 'secret');
125+
assert.equal('peer' in msg, false);
126+
assert.equal('extra' in msg, false);
127+
});
128+
129+
it('NIP44_DECRYPT forwards only pubkey + ciphertext', async () => {
130+
await request({ type: 'NIP44_DECRYPT', pubkey: 'f'.repeat(64), ciphertext: 'BASE64', junk: 1 });
131+
const msg = forwarded[0];
132+
assert.deepEqual(Object.keys(msg).sort(), ['ciphertext', 'origin', 'pubkey', 'type']);
133+
assert.equal('junk' in msg, false);
134+
});
135+
136+
it('coerces NIP44 fields to strings', async () => {
137+
await request({ type: 'NIP44_ENCRYPT', pubkey: 123, plaintext: 456 });
138+
const msg = forwarded[0];
139+
assert.equal(typeof msg.pubkey, 'string');
140+
assert.equal(typeof msg.plaintext, 'string');
141+
});
142+
});

0 commit comments

Comments
 (0)