From 26144b4b9341bff301fc37583ac591aa1ddbf48f Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 23 Apr 2026 13:51:09 +0200 Subject: [PATCH 1/6] Don't overwrite existing Authorization headers (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Podkey's fetch/XHR interceptors unconditionally set Authorization on every outbound request, replacing whatever the page already put there. Pages using Solid-OIDC (Authorization: DPoP ) have their auth silently swapped for Podkey's NIP-98 signature — the server then authenticates as did:nostr:, doesn't match the user's real WebID, and denies access. User gets 403 on their own pod. Fix: before injecting NIP-98, check whether the page already set Authorization. If yes, step aside and let the page authenticate itself. If that auth gets a 401, the existing retry-on-401 path still tries NIP-98 as a fallback. Covers both fetch (options.headers across Headers/object/array shapes) and XHR (via a setRequestHeader override that tags the request). New hasAuthorizationHeader helper is canonical in src/auth-header-utils.js with unit tests. The in-page scripts duplicate it because they load as classic scripts without ESM imports. --- src/auth-header-utils.js | 110 +++++++++++++++++++ src/injected.js | 194 +++++++++++++++++++++++++-------- src/nip98-interceptor.js | 136 ++++++++++++++++++----- test/auth-header-utils.test.js | 172 +++++++++++++++++++++++++++++ 4 files changed, 542 insertions(+), 70 deletions(-) create mode 100644 src/auth-header-utils.js create mode 100644 test/auth-header-utils.test.js diff --git a/src/auth-header-utils.js b/src/auth-header-utils.js new file mode 100644 index 0000000..b1e318a --- /dev/null +++ b/src/auth-header-utils.js @@ -0,0 +1,110 @@ +/** + * Utility: detect whether a fetch-options / headers shape already carries + * an Authorization header. Podkey's interceptors must not overwrite an + * Authorization header the page already set (e.g., Solid-OIDC DPoP), + * otherwise the request ends up authenticated as Podkey's NIP-98 identity + * and ACLs reject the real user (see issue #5). + * + * Handles all three shapes `options.headers` can take per the fetch spec: + * - `Headers` instance + * - plain object { 'Authorization': '...' } + * - array of [name, value] tuples + */ +export function hasAuthorizationHeader (headers) { + if (!headers) return false; + + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + return headers.has('authorization'); + } + + if (Array.isArray(headers)) { + return headers.some((entry) => + Array.isArray(entry) && typeof entry[0] === 'string' && + entry[0].toLowerCase() === 'authorization' + ); + } + + if (typeof headers === 'object') { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'authorization') return true; + } + } + + return false; +} + +/** + * Detect whether a `fetch(input, init)` call already carries an Authorization + * header anywhere — either on `init.headers` or on the input when it is a + * `Request` object. + * + * Per the fetch spec, when `input` is a `Request` and `init.headers` is + * supplied, `init.headers` overrides the Request's headers entirely. + * Therefore `init.headers` is authoritative when present, and + * `input.headers` is only consulted when `init.headers` is absent. + */ +export function fetchCallHasAuthorization (input, init) { + if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) { + return hasAuthorizationHeader(init.headers); + } + if (typeof Request !== 'undefined' && input instanceof Request) { + return hasAuthorizationHeader(input.headers); + } + return false; +} + +/** + * Extract `{ url, method, body }` from a `fetch(input, init)` call so + * downstream signers (NIP-98) see the actual URL/method regardless of + * whether the caller used `fetch(url, init)` or `fetch(new Request(...))`. + * + * Per the fetch spec, when input is a Request and init supplies a method, + * init's method wins. Body is read from init when provided; we do not read + * from Request.body here because that consumes the stream — callers that + * need body-hashing for Request inputs should clone before signing. + */ +export function normalizeFetchCall (input, init) { + if (typeof Request !== 'undefined' && input instanceof Request) { + return { + url: input.url, + method: init?.method || input.method || 'GET', + body: init?.body + }; + } + return { + url: typeof input === 'string' ? input : String(input), + method: init?.method || 'GET', + body: init?.body + }; +} + +/** + * Set an Authorization header on `options.headers`, normalizing any of the + * three supported shapes so the assignment actually takes effect: + * - `Headers` instance → `.set('Authorization', value)` + * - plain object → `headers.Authorization = value` + * - array of tuples → normalized to `Headers` (so the array shape + * doesn't silently swallow the injection) + * Mutates `options` in place and returns the updated headers for clarity. + */ +export function setAuthorizationOnOptions (options, value) { + options.headers = options.headers || {}; + if (typeof Headers !== 'undefined' && options.headers instanceof Headers) { + options.headers.set('Authorization', value); + } else if (Array.isArray(options.headers)) { + // Assigning an 'Authorization' property to an array would not add a + // header, so normalize to `Headers` first. + const normalized = new Headers(options.headers); + normalized.set('Authorization', value); + options.headers = normalized; + } else { + // Delete any existing case-insensitive match before setting the new + // value; otherwise fetch may see both keys and merge them into a + // "DPoP …, Nostr …" comma-joined header. + for (const key of Object.keys(options.headers)) { + if (key.toLowerCase() === 'authorization') delete options.headers[key]; + } + options.headers['Authorization'] = value; + } + return options.headers; +} diff --git a/src/injected.js b/src/injected.js index d429b5e..b77f08b 100644 --- a/src/injected.js +++ b/src/injected.js @@ -12,6 +12,75 @@ const originalFetch = window.fetch; const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; + const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + + // Duplicate of src/auth-header-utils.js — keep in sync. The canonical + // module is imported by unit tests; this copy runs in content-script + // context where classic scripts can't use ESM imports. Build-time + // bundling to drop the duplication is tracked in #7. + function hasAuthorizationHeader (headers) { + if (!headers) return false; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + return headers.has('authorization'); + } + if (Array.isArray(headers)) { + return headers.some((entry) => + Array.isArray(entry) && typeof entry[0] === 'string' && + entry[0].toLowerCase() === 'authorization' + ); + } + if (typeof headers === 'object') { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'authorization') return true; + } + } + return false; + } + + // Per fetch spec, init.headers overrides Request.headers entirely — so + // init.headers is authoritative when present. Only consult input.headers + // when init omits the headers key. + function fetchCallHasAuthorization (input, init) { + if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) { + return hasAuthorizationHeader(init.headers); + } + if (typeof Request !== 'undefined' && input instanceof Request) { + return hasAuthorizationHeader(input.headers); + } + return false; + } + + function setAuthorizationOnOptions (options, value) { + options.headers = options.headers || {}; + if (typeof Headers !== 'undefined' && options.headers instanceof Headers) { + options.headers.set('Authorization', value); + } else if (Array.isArray(options.headers)) { + const normalized = new Headers(options.headers); + normalized.set('Authorization', value); + options.headers = normalized; + } else { + for (const key of Object.keys(options.headers)) { + if (key.toLowerCase() === 'authorization') delete options.headers[key]; + } + options.headers['Authorization'] = value; + } + return options.headers; + } + + function normalizeFetchCall (input, init) { + if (typeof Request !== 'undefined' && input instanceof Request) { + return { + url: input.url, + method: init?.method || input.method || 'GET', + body: init?.body + }; + } + return { + url: typeof input === 'string' ? input : String(input), + method: init?.method || 'GET', + body: init?.body + }; + } // Helper to get auth header (will be async, but we'll handle that) let getAuthHeaderFn = null; @@ -24,26 +93,31 @@ // Intercept fetch - MUST replace immediately to catch all calls // This runs synchronously, so it catches fetch even if called immediately window.fetch = function (url, options = {}) { - const urlString = typeof url === 'string' ? url : url.toString(); - const method = options?.method || 'GET'; + // Default-param `= {}` only applies for `undefined`; `fetch(url, null)` + // passes null through. Coerce once so the rest of the wrapper can + // assume an object. + options = options || {}; + // Normalize once — handles fetch(url, init) and fetch(new Request(...)) + // so downstream signing sees the real URL/method, not "[object Request]". + const { url: urlString, method, body } = normalizeFetchCall(url, options); console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); + // Respect an Authorization header the page already set — on either + // options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting + // would re-identify the request as Podkey's NIP-98 and break the page's + // own auth. If it fails with 401, the retry path below still injects + // NIP-98. See issue #5. + const pageSetAuth = fetchCallHasAuthorization(url, options); + // If we have the auth function, use it - if (authFunctionReady && getAuthHeaderFn) { + if (getAuthHeaderFn && !pageSetAuth) { console.log('[Podkey] ✅ Auth function ready, adding header...'); const promise = (async () => { try { - const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); + const authHeader = await getAuthHeaderFn(urlString, method, body); if (authHeader) { - options = options || {}; - options.headers = options.headers || {}; - if (options.headers instanceof Headers) { - options.headers.set('Authorization', authHeader); - console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)'); - } else { - options.headers['Authorization'] = authHeader; - console.log('[Podkey] ✅ Added NIP-98 auth header (object)'); - } + setAuthorizationOnOptions(options, authHeader); + console.log('[Podkey] ✅ Added NIP-98 auth header'); } else { console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)'); } @@ -59,15 +133,10 @@ console.log('[Podkey] 🔄 401 detected, retrying with auth...'); return (async () => { try { - const authHeader = await getAuthHeaderFn(url, options.method || 'GET', options.body); + const authHeader = await getAuthHeaderFn(urlString, method, body); if (authHeader) { const retryOptions = { ...options }; - retryOptions.headers = retryOptions.headers || {}; - if (retryOptions.headers instanceof Headers) { - retryOptions.headers.set('Authorization', authHeader); - } else { - retryOptions.headers['Authorization'] = authHeader; - } + setAuthorizationOnOptions(retryOptions, authHeader); console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); const retryResponse = await originalFetch.call(this, url, retryOptions); if (retryResponse.status === 200 || retryResponse.status === 201) { @@ -87,35 +156,55 @@ }); } - // Fallback if auth function not ready yet - but still try to get auth - console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); + // Fall-through branch: we're here because either the page set its own + // Authorization (and we deliberately skipped initial injection) or the + // auth function isn't wired up yet. Send the request as-is; on 401, + // retry with NIP-98 — either immediately if ready, or after waiting + // for setup (with a hard deadline so we don't hang forever). + if (pageSetAuth) { + console.log('[Podkey] ⏭️ Page already set Authorization — skipping initial injection'); + } else { + console.log('[Podkey] ⚠️ Auth function not ready yet, making request...'); + } - // Even if not ready, try to get auth header asynchronously and retry on 401 const requestPromise = originalFetch.call(this, url, options); + const AUTH_READY_DEADLINE_MS = 5000; + // If we get a 401 and auth becomes available, retry return requestPromise.then(response => { if (response.status === 401) { console.log('[Podkey] 🔄 Got 401, checking if auth function is ready now...'); - // Wait a bit for auth function to be ready, then retry + // Wait for auth function to be ready, bounded by deadline. Every + // async step below has an error handler so the outer promise is + // guaranteed to settle (otherwise the caller would hang). return new Promise((resolve) => { + const deadline = Date.now() + AUTH_READY_DEADLINE_MS; const checkAuth = () => { - if (authFunctionReady && getAuthHeaderFn) { + if (getAuthHeaderFn) { console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...'); - getAuthHeaderFn(url, method, options?.body).then(authHeader => { - if (authHeader) { - const retryOptions = { ...options }; - retryOptions.headers = retryOptions.headers || {}; - if (retryOptions.headers instanceof Headers) { - retryOptions.headers.set('Authorization', authHeader); + getAuthHeaderFn(urlString, method, body) + .then(authHeader => { + if (authHeader) { + const retryOptions = { ...options }; + setAuthorizationOnOptions(retryOptions, authHeader); + originalFetch.call(this, url, retryOptions) + .then(resolve) + .catch(err => { + console.error('[Podkey] Retry fetch failed:', err); + resolve(response); + }); } else { - retryOptions.headers['Authorization'] = authHeader; + resolve(response); } - originalFetch.call(this, url, retryOptions).then(resolve); - } else { + }) + .catch(err => { + console.error('[Podkey] Error getting auth header for retry:', err); resolve(response); - } - }); + }); + } else if (Date.now() >= deadline) { + console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up'); + resolve(response); } else { // Check again in 100ms setTimeout(checkAuth, 100); @@ -132,23 +221,38 @@ XMLHttpRequest.prototype.open = function (method, url, ...args) { this._podkeyMethod = method; this._podkeyUrl = url; + this._podkeyHasPageAuth = false; return originalXHROpen.apply(this, [method, url, ...args]); }; + // Track a page-set Authorization on XHR so send() can respect it. + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { + if (typeof name === 'string' && name.toLowerCase() === 'authorization') { + this._podkeyHasPageAuth = true; + } + return originalXHRSetRequestHeader.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function (body) { - if (getAuthHeaderFn && this._podkeyUrl) { - (async () => { - try { - const authHeader = await getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body); + // setRequestHeader must run before send(); defer originalXHRSend until + // the header is applied (or skipped), else the async setRequestHeader + // would fire after the request is already in-flight and throw + // InvalidStateError. + const runSend = () => originalXHRSend.apply(this, [body]); + if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) { + getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body) + .then(authHeader => { if (authHeader) { - this.setRequestHeader('Authorization', authHeader); + originalXHRSetRequestHeader.call(this, 'Authorization', authHeader); } - } catch (e) { + }) + .catch(e => { console.error('[Podkey] Error in XHR interceptor:', e); - } - })(); + }) + .finally(runSend); + } else { + runSend(); } - return originalXHRSend.apply(this, [body]); }; // Expose setter for the auth function diff --git a/src/nip98-interceptor.js b/src/nip98-interceptor.js index 037cd0a..029a1cc 100644 --- a/src/nip98-interceptor.js +++ b/src/nip98-interceptor.js @@ -16,6 +16,75 @@ const originalFetch = window.fetch; const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; + const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader; + + // Duplicate of src/auth-header-utils.js — keep in sync. The canonical + // module is imported by unit tests; this copy runs in page context where + // classic scripts can't use ESM imports. Build-time bundling to drop the + // duplication is tracked in #7. + function hasAuthorizationHeader (headers) { + if (!headers) return false; + if (typeof Headers !== 'undefined' && headers instanceof Headers) { + return headers.has('authorization'); + } + if (Array.isArray(headers)) { + return headers.some((entry) => + Array.isArray(entry) && typeof entry[0] === 'string' && + entry[0].toLowerCase() === 'authorization' + ); + } + if (typeof headers === 'object') { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'authorization') return true; + } + } + return false; + } + + // Per fetch spec, init.headers overrides Request.headers entirely — so + // init.headers is authoritative when present. Only consult input.headers + // when init omits the headers key. + function fetchCallHasAuthorization (input, init) { + if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) { + return hasAuthorizationHeader(init.headers); + } + if (typeof Request !== 'undefined' && input instanceof Request) { + return hasAuthorizationHeader(input.headers); + } + return false; + } + + function setAuthorizationOnOptions (options, value) { + options.headers = options.headers || {}; + if (typeof Headers !== 'undefined' && options.headers instanceof Headers) { + options.headers.set('Authorization', value); + } else if (Array.isArray(options.headers)) { + const normalized = new Headers(options.headers); + normalized.set('Authorization', value); + options.headers = normalized; + } else { + for (const key of Object.keys(options.headers)) { + if (key.toLowerCase() === 'authorization') delete options.headers[key]; + } + options.headers['Authorization'] = value; + } + return options.headers; + } + + function normalizeFetchCall (input, init) { + if (typeof Request !== 'undefined' && input instanceof Request) { + return { + url: input.url, + method: init?.method || input.method || 'GET', + body: init?.body + }; + } + return { + url: typeof input === 'string' ? input : String(input), + method: init?.method || 'GET', + body: init?.body + }; + } // Helper to get auth header from extension async function getNip98AuthHeader (url, method, body) { @@ -59,27 +128,36 @@ // Intercept fetch window.fetch = async function (url, options = {}) { - const urlString = typeof url === 'string' ? url : url.toString(); - const method = options?.method || 'GET'; + // Default-param `= {}` only applies for `undefined`; `fetch(url, null)` + // passes null through. Coerce once so the rest of the wrapper can + // assume an object. + options = options || {}; + // Normalize once — handles fetch(url, init) and fetch(new Request(...)) + // so downstream signing sees the real URL/method, not "[object Request]". + const { url: urlString, method, body } = normalizeFetchCall(url, options); console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method); - try { - const authHeader = await getNip98AuthHeader(url, method, options?.body); - if (authHeader) { - options = options || {}; - options.headers = options.headers || {}; - if (options.headers instanceof Headers) { - options.headers.set('Authorization', authHeader); - console.log('[Podkey] ✅ Added NIP-98 auth header (Headers)'); + // Respect an Authorization header the page already set — on either + // options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting + // would re-identify the request as Podkey's NIP-98 and break the page's + // own auth. If that auth fails with 401, the retry path below still + // injects NIP-98. See issue #5. + const pageSetAuth = fetchCallHasAuthorization(url, options); + + if (!pageSetAuth) { + try { + const authHeader = await getNip98AuthHeader(urlString, method, body); + if (authHeader) { + setAuthorizationOnOptions(options, authHeader); + console.log('[Podkey] ✅ Added NIP-98 auth header'); } else { - options.headers['Authorization'] = authHeader; - console.log('[Podkey] ✅ Added NIP-98 auth header (object)'); + console.log('[Podkey] ⚠️ No auth header (will retry on 401)'); } - } else { - console.log('[Podkey] ⚠️ No auth header (will retry on 401)'); + } catch (error) { + console.error('[Podkey] Error adding NIP-98 auth:', error); } - } catch (error) { - console.error('[Podkey] Error adding NIP-98 auth:', error); + } else { + console.log('[Podkey] ⏭️ Page already set Authorization — skipping injection'); } const response = await originalFetch.call(this, url, options); @@ -88,15 +166,10 @@ if (response.status === 401) { console.log('[Podkey] 🔄 401 detected, retrying with NIP-98 auth...'); try { - const authHeader = await getNip98AuthHeader(url, method, options?.body); + const authHeader = await getNip98AuthHeader(urlString, method, body); if (authHeader) { const retryOptions = { ...options }; - retryOptions.headers = retryOptions.headers || {}; - if (retryOptions.headers instanceof Headers) { - retryOptions.headers.set('Authorization', authHeader); - } else { - retryOptions.headers['Authorization'] = authHeader; - } + setAuthorizationOnOptions(retryOptions, authHeader); console.log('[Podkey] 🔄 Retrying with NIP-98 auth...'); const retryResponse = await originalFetch.call(this, url, retryOptions); if (retryResponse.status === 200 || retryResponse.status === 201) { @@ -118,20 +191,33 @@ XMLHttpRequest.prototype.open = function (method, url, ...args) { this._podkeyMethod = method; this._podkeyUrl = url; + this._podkeyHasPageAuth = false; return originalXHROpen.apply(this, [method, url, ...args]); }; + // Track page-set Authorization on XHR so we don't overwrite it in send(). + // setRequestHeader auto-merges values per the XHR spec, but a merged + // "DPoP xxx, Nostr yyy" still confuses servers that branch on scheme. + XMLHttpRequest.prototype.setRequestHeader = function (name, value) { + if (typeof name === 'string' && name.toLowerCase() === 'authorization') { + this._podkeyHasPageAuth = true; + } + return originalXHRSetRequestHeader.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = async function (body) { - if (this._podkeyUrl) { + if (this._podkeyUrl && !this._podkeyHasPageAuth) { try { const authHeader = await getNip98AuthHeader(this._podkeyUrl, this._podkeyMethod, body); if (authHeader) { - this.setRequestHeader('Authorization', authHeader); + originalXHRSetRequestHeader.call(this, 'Authorization', authHeader); console.log('[Podkey] ✅ Added NIP-98 auth to XHR'); } } catch (error) { console.error('[Podkey] Error adding NIP-98 auth to XHR:', error); } + } else if (this._podkeyHasPageAuth) { + console.log('[Podkey] ⏭️ Page set XHR Authorization — skipping injection'); } return originalXHRSend.apply(this, [body]); }; diff --git a/test/auth-header-utils.test.js b/test/auth-header-utils.test.js new file mode 100644 index 0000000..6b4f22e --- /dev/null +++ b/test/auth-header-utils.test.js @@ -0,0 +1,172 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + hasAuthorizationHeader, + fetchCallHasAuthorization, + setAuthorizationOnOptions, + normalizeFetchCall +} from '../src/auth-header-utils.js'; + +describe('hasAuthorizationHeader (#5)', () => { + it('returns false for undefined / empty', () => { + assert.strictEqual(hasAuthorizationHeader(undefined), false); + assert.strictEqual(hasAuthorizationHeader(null), false); + assert.strictEqual(hasAuthorizationHeader({}), false); + }); + + it('detects plain-object Authorization (exact case)', () => { + assert.strictEqual(hasAuthorizationHeader({ Authorization: 'DPoP xxx' }), true); + }); + + it('detects plain-object authorization case-insensitively', () => { + assert.strictEqual(hasAuthorizationHeader({ authorization: 'DPoP xxx' }), true); + assert.strictEqual(hasAuthorizationHeader({ AUTHORIZATION: 'DPoP xxx' }), true); + assert.strictEqual(hasAuthorizationHeader({ aUtHoRiZaTiOn: 'DPoP xxx' }), true); + }); + + it('returns false when plain object has unrelated headers', () => { + assert.strictEqual( + hasAuthorizationHeader({ 'Content-Type': 'application/json', 'X-Foo': 'bar' }), + false + ); + }); + + it('detects Headers instance (case-insensitive per spec)', () => { + const h = new Headers(); + h.set('Authorization', 'DPoP xxx'); + assert.strictEqual(hasAuthorizationHeader(h), true); + assert.strictEqual(hasAuthorizationHeader(new Headers()), false); + }); + + it('detects array-of-tuples shape', () => { + assert.strictEqual( + hasAuthorizationHeader([['Content-Type', 'application/json'], ['authorization', 'Bearer x']]), + true + ); + assert.strictEqual( + hasAuthorizationHeader([['Content-Type', 'application/json']]), + false + ); + }); + + it('does not false-positive on header names that merely contain "authorization"', () => { + assert.strictEqual( + hasAuthorizationHeader({ 'X-Authorization-Source': 'podkey' }), + false + ); + }); +}); + +describe('fetchCallHasAuthorization (#5)', () => { + it('returns false for fetch(url) with no init', () => { + assert.strictEqual(fetchCallHasAuthorization('https://x.test/', undefined), false); + }); + + it('returns true when init.headers carries Authorization', () => { + assert.strictEqual( + fetchCallHasAuthorization('https://x.test/', { headers: { Authorization: 'DPoP x' } }), + true + ); + }); + + it('returns true when a Request input carries Authorization (no init override)', () => { + const req = new Request('https://x.test/', { + headers: { Authorization: 'DPoP x' } + }); + assert.strictEqual(fetchCallHasAuthorization(req, undefined), true); + }); + + it('returns false for a Request input without Authorization', () => { + const req = new Request('https://x.test/'); + assert.strictEqual(fetchCallHasAuthorization(req, undefined), false); + }); + + it('init.headers overrides Request.headers (no false positive from Request)', () => { + // Per fetch spec, init.headers completely replaces Request.headers. + // If Request had auth but init supplies its own (non-auth) headers, + // the effective request has NO auth and we must NOT skip injection. + const req = new Request('https://x.test/', { + headers: { Authorization: 'DPoP old' } + }); + assert.strictEqual( + fetchCallHasAuthorization(req, { headers: { 'Content-Type': 'application/json' } }), + false + ); + }); + + it('init.headers with Authorization is detected even when Request has none', () => { + const req = new Request('https://x.test/'); + assert.strictEqual( + fetchCallHasAuthorization(req, { headers: { Authorization: 'DPoP new' } }), + true + ); + }); +}); + +describe('setAuthorizationOnOptions (#5)', () => { + it('sets on a plain object', () => { + const options = { headers: { 'Content-Type': 'application/json' } }; + setAuthorizationOnOptions(options, 'Nostr xyz'); + assert.strictEqual(options.headers['Authorization'], 'Nostr xyz'); + }); + + it('creates headers object when missing', () => { + const options = {}; + setAuthorizationOnOptions(options, 'Nostr xyz'); + assert.strictEqual(options.headers['Authorization'], 'Nostr xyz'); + }); + + it('sets on a Headers instance via .set', () => { + const options = { headers: new Headers({ 'Content-Type': 'application/json' }) }; + setAuthorizationOnOptions(options, 'Nostr xyz'); + assert.strictEqual(options.headers.get('authorization'), 'Nostr xyz'); + }); + + it('replaces an existing Authorization across casings (no comma-merge)', () => { + // 401-retry path intentionally overwrites; the old lowercase key must be + // removed so fetch doesn't end up merging "DPoP …, Nostr …". + const options = { headers: { authorization: 'DPoP original', 'Content-Type': 'application/json' } }; + setAuthorizationOnOptions(options, 'Nostr replacement'); + const authKeys = Object.keys(options.headers).filter((k) => k.toLowerCase() === 'authorization'); + assert.strictEqual(authKeys.length, 1, `expected exactly one auth key, got: ${authKeys.join(',')}`); + assert.strictEqual(options.headers[authKeys[0]], 'Nostr replacement'); + }); + + it('normalizes array-of-tuples to Headers and sets there', () => { + // A raw array would silently swallow `options.headers['Authorization'] = x`; + // normalization is what actually makes the injection take effect. + const options = { headers: [['Content-Type', 'application/json']] }; + setAuthorizationOnOptions(options, 'Nostr xyz'); + assert.ok(options.headers instanceof Headers, 'headers should be normalized to Headers'); + assert.strictEqual(options.headers.get('authorization'), 'Nostr xyz'); + // Original header preserved through the normalization. + assert.strictEqual(options.headers.get('content-type'), 'application/json'); + }); +}); + +describe('normalizeFetchCall (#5)', () => { + it('extracts url/method from a string input and init', () => { + const n = normalizeFetchCall('https://x.test/a', { method: 'PUT', body: 'hello' }); + assert.strictEqual(n.url, 'https://x.test/a'); + assert.strictEqual(n.method, 'PUT'); + assert.strictEqual(n.body, 'hello'); + }); + + it('defaults method to GET when init omits it', () => { + const n = normalizeFetchCall('https://x.test/', undefined); + assert.strictEqual(n.method, 'GET'); + }); + + it('extracts url/method from a Request input (not "[object Request]")', () => { + const req = new Request('https://x.test/b', { method: 'POST' }); + const n = normalizeFetchCall(req, undefined); + assert.strictEqual(n.url, 'https://x.test/b'); + assert.strictEqual(n.method, 'POST'); + }); + + it('init method overrides Request method when both present (per fetch spec)', () => { + const req = new Request('https://x.test/b', { method: 'POST' }); + const n = normalizeFetchCall(req, { method: 'PUT' }); + assert.strictEqual(n.method, 'PUT'); + }); +}); From f53c6d7178254f9a7b395898e939da6d1de33f34 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:18:55 +0000 Subject: [PATCH 2/6] security: remove broken crypto-browser.js that leaks private key material crypto-browser.js contained a completely broken "Schnorr" implementation that: - Derived public keys via SHA-256(private_key) instead of secp256k1 scalar multiplication - Created "signatures" as SHA-256(private_key || event_id) duplicated to 128 hex chars - This means signatures contained material directly derived from the private key, enabling private key recovery through known-plaintext analysis While the file is not imported in the current bundled build (background.js imports from crypto.js which uses @noble/secp256k1), its presence in the source tree is an active hazard: any future refactor, build configuration change, or browser field mapping in package.json could silently activate this broken implementation. The correct implementation lives in src/crypto.js using @noble/secp256k1 with proper Schnorr signatures per BIP-340 / NIP-01. Co-Authored-By: claude-flow --- src/crypto-browser.js | 133 ------------------------------------------ 1 file changed, 133 deletions(-) delete mode 100644 src/crypto-browser.js diff --git a/src/crypto-browser.js b/src/crypto-browser.js deleted file mode 100644 index 42e1d8b..0000000 --- a/src/crypto-browser.js +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Podkey - Browser-native cryptographic operations for Nostr keys - * Uses Web Crypto API - no external dependencies needed - */ - -/** - * Generate a new Nostr keypair - * @returns {Promise<{privateKey: string, publicKey: string}>} 64-char hex keys - */ -export async function generateKeypair() { - // Generate random 32 bytes for private key - const privateKeyBytes = new Uint8Array(32); - crypto.getRandomValues(privateKeyBytes); - - const privateKey = bytesToHex(privateKeyBytes); - - // For now, derive a deterministic public key from private key hash - // Note: This is a simplified version. For production, you'd want proper secp256k1 - const publicKeyBytes = await sha256(privateKeyBytes); - const publicKey = bytesToHex(publicKeyBytes); - - return { privateKey, publicKey }; -} - -/** - * Get public key from private key - * @param {string} privateKeyHex - 64-char hex private key - * @returns {Promise} 64-char hex public key - */ -export async function getPublicKey(privateKeyHex) { - const privateKeyBytes = hexToBytes(privateKeyHex); - const publicKeyBytes = await sha256(privateKeyBytes); - return bytesToHex(publicKeyBytes); -} - -/** - * Sign a Nostr event - * @param {object} event - Unsigned Nostr event - * @param {string} privateKeyHex - 64-char hex private key - * @returns {Promise} Signed event with id and sig - */ -export async function signEvent(event, privateKeyHex) { - // Calculate event ID - const eventId = await getEventHash(event); - - // Create signature (simplified - combines private key and event ID) - const privateKeyBytes = hexToBytes(privateKeyHex); - const eventIdBytes = hexToBytes(eventId); - - const combined = new Uint8Array(privateKeyBytes.length + eventIdBytes.length); - combined.set(privateKeyBytes); - combined.set(eventIdBytes, privateKeyBytes.length); - - const signatureBytes = await sha256(combined); - // Double the signature to get 128 chars (64 bytes) - const signature = bytesToHex(signatureBytes) + bytesToHex(signatureBytes); - - // Get public key - const pubkey = await getPublicKey(privateKeyHex); - - return { - ...event, - id: eventId, - pubkey, - sig: signature - }; -} - -/** - * Calculate event hash (ID) - * @param {object} event - Event object - * @returns {Promise} 64-char hex event ID - */ -export async function getEventHash(event) { - // Serialize event according to NIP-01 - const serialized = JSON.stringify([ - 0, // Reserved for future use - event.pubkey || '', - event.created_at, - event.kind, - event.tags || [], - event.content || '' - ]); - - const bytes = new TextEncoder().encode(serialized); - const hash = await sha256(bytes); - return bytesToHex(hash); -} - -/** - * SHA-256 hash using Web Crypto API - * @param {Uint8Array} data - Data to hash - * @returns {Promise} Hash result - */ -async function sha256(data) { - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - return new Uint8Array(hashBuffer); -} - -/** - * Convert bytes to hex string - * @param {Uint8Array} bytes - Bytes to convert - * @returns {string} Hex string - */ -function bytesToHex(bytes) { - return Array.from(bytes) - .map(b => b.toString(16).padStart(2, '0')) - .join(''); -} - -/** - * Convert hex string to bytes - * @param {string} hex - Hex string to convert - * @returns {Uint8Array} Bytes - */ -function hexToBytes(hex) { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16); - } - return bytes; -} - -/** - * Validate public key format (64-char hex) - * @param {string} publicKey - Public key to validate - * @returns {boolean} True if valid - */ -export function isValidPublicKey(publicKey) { - if (typeof publicKey !== 'string') return false; - if (publicKey.length !== 64) return false; - return /^[0-9a-fA-F]{64}$/.test(publicKey); -} From 6224437308dab8e16c813d7659cd2fd15f2f63ef Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:01 +0000 Subject: [PATCH 3/6] security: add explicit Content Security Policy to manifest MV3 has restrictive CSP defaults, but an explicit policy documents the intended security posture, prevents accidental relaxation during future development, and makes the extension's trust boundary auditable at a glance. Co-Authored-By: claude-flow --- manifest.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/manifest.json b/manifest.json index 8c38138..3a7e343 100644 --- a/manifest.json +++ b/manifest.json @@ -3,6 +3,9 @@ "name": "Podkey", "version": "0.0.7", "description": "did:nostr and Solid authentication extension - NIP-07 wallet for decentralized identity", + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "permissions": [ "storage", "webRequest" From 00e13d82a4c4aa49f3cf1d2b87eff399ef4e7e15 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:04 +0000 Subject: [PATCH 4/6] fix: remove unused webRequest permission from manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webRequest permission is declared but never used — the code explicitly notes that webRequestBlocking is deprecated in MV3 and uses JavaScript-level fetch/XHR interception instead. Removing this permission reduces the extension's privilege surface and avoids triggering unnecessary review during Chrome Web Store submission. Co-Authored-By: claude-flow --- manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index 8c38138..5db36c0 100644 --- a/manifest.json +++ b/manifest.json @@ -4,8 +4,7 @@ "version": "0.0.7", "description": "did:nostr and Solid authentication extension - NIP-07 wallet for decentralized identity", "permissions": [ - "storage", - "webRequest" + "storage" ], "host_permissions": [ "" From 1072ba5dc3f4fda60b51ee196b3cfbc9614f2b28 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:14 +0000 Subject: [PATCH 5/6] fix: remove broken bech32 nsec/npub stub functions The privateKeyToNsec, nsecToPrivateKey, and publicKeyToNpub functions emitted fake nsec_ / npub_ strings that are not valid NIP-19 bech32 encoding. These stubs could confuse users into thinking they have a valid key backup, and any interoperability with other Nostr clients would silently fail. The functions are not imported or called anywhere in the codebase, so they are removed entirely rather than replaced with throwing stubs. Proper NIP-19 bech32 support can be added when needed via a bech32 library. Co-Authored-By: claude-flow --- src/crypto.js | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/crypto.js b/src/crypto.js index 46fa822..181f4ce 100644 --- a/src/crypto.js +++ b/src/crypto.js @@ -161,35 +161,3 @@ export function isValidPublicKey (publicKey) { if (publicKey.length !== 64) return false; return /^[0-9a-fA-F]{64}$/.test(publicKey); } - -/** - * Convert private key to nsec format (Bech32) - * @param {string} privateKeyHex - 64-char hex private key - * @returns {string} nsec1... format - */ -export function privateKeyToNsec (privateKeyHex) { - // TODO: Implement bech32 encoding - // For now, return hex with nsec prefix as placeholder - return `nsec_${privateKeyHex}`; -} - -/** - * Convert nsec to private key hex - * @param {string} nsec - nsec1... format - * @returns {string} 64-char hex private key - */ -export function nsecToPrivateKey (nsec) { - // TODO: Implement bech32 decoding - // For now, strip nsec_ prefix as placeholder - return nsec.replace(/^nsec_/, ''); -} - -/** - * Convert public key to npub format (Bech32) - * @param {string} publicKeyHex - 64-char hex public key - * @returns {string} npub1... format - */ -export function publicKeyToNpub (publicKeyHex) { - // TODO: Implement bech32 encoding - return `npub_${publicKeyHex}`; -} From 60be0f788ded212c43e26834a016a0e5b3e70c47 Mon Sep 17 00:00:00 2001 From: DreamLab-AI Mega-Sprint Date: Tue, 12 May 2026 14:20:22 +0000 Subject: [PATCH 6/6] security: sanitize HTML in popup trusted-origins list to prevent XSS Replace innerHTML template literal interpolation with DOM API methods (createElement, textContent, appendChild) when rendering the trusted origins list. The previous code interpolated the origin string directly into HTML, allowing an attacker who injects a malicious string into the trusted origins (e.g. ) to execute arbitrary JavaScript in the popup's privileged extension context, potentially exfiltrating the user's private key. Co-Authored-By: claude-flow --- popup/popup.js | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/popup/popup.js b/popup/popup.js index 80af088..e6bd89b 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -247,23 +247,28 @@ async function loadTrustedSites() { return; } - listEl.innerHTML = origins - .sort() - .map(origin => ` -
- ${origin} - -
- `) - .join(''); - - // Add remove button handlers - listEl.querySelectorAll('.btn-remove').forEach(btn => { + listEl.innerHTML = ''; + origins.sort().forEach(origin => { + const div = document.createElement('div'); + div.className = 'trusted-item'; + + const span = document.createElement('span'); + span.className = 'trusted-origin'; + span.textContent = origin; + + const btn = document.createElement('button'); + btn.className = 'btn-remove'; + btn.dataset.origin = origin; + btn.textContent = 'Remove'; + btn.addEventListener('click', async () => { - const origin = btn.dataset.origin; await removeTrustedSite(origin); await loadTrustedSites(); // Reload }); + + div.appendChild(span); + div.appendChild(btn); + listEl.appendChild(div); }); }