Skip to content

Commit d109a41

Browse files
DreamLab-AI Mega-Sprintruvnet
andcommitted
fix: remove duplicate fetch/XHR interception from content script
Both src/injected.js (content script context) and src/nip98-interceptor.js (page context) were independently overriding window.fetch and XMLHttpRequest.prototype.send. This caused double interception of every request, double auth header injection, and double 401 retry loops. The content script's interception was also coupled to a window.__podkey_setAuthFn callback that was never properly wired — getAuthHeaderFn started as null and relied on an async setup race. This commit removes all fetch/XHR interception code from injected.js and leaves NIP-98 request interception solely to nip98-interceptor.js, which already has its own clean implementation using CustomEvent-based message passing to the content script. The content script now only handles what it should: relaying podkey-request/podkey-response events for NIP-07 and podkey-nip98-request/response events for NIP-98. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent b83f414 commit d109a41

1 file changed

Lines changed: 11 additions & 310 deletions

File tree

src/injected.js

Lines changed: 11 additions & 310 deletions
Original file line numberDiff line numberDiff line change
@@ -1,266 +1,13 @@
11
/**
22
* Podkey - Content Script
3-
* Bridges between injected window.nostr and background script
3+
* Bridges between injected window.nostr and background script.
4+
*
5+
* Fetch/XHR interception for NIP-98 auth headers is handled exclusively
6+
* by src/nip98-interceptor.js (injected into the page context below).
7+
* This content script only relays messages between the page and the
8+
* extension background via CustomEvents.
49
*/
510

6-
// CRITICAL: Set up fetch/XHR interception IMMEDIATELY, synchronously
7-
// This must run before ANY other code, including page scripts
8-
(function setupInterceptionImmediately () {
9-
'use strict';
10-
11-
// Store original functions
12-
const originalFetch = window.fetch;
13-
const originalXHROpen = XMLHttpRequest.prototype.open;
14-
const originalXHRSend = XMLHttpRequest.prototype.send;
15-
const originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
16-
17-
// Duplicate of src/auth-header-utils.js — keep in sync. The canonical
18-
// module is imported by unit tests; this copy runs in content-script
19-
// context where classic scripts can't use ESM imports. Build-time
20-
// bundling to drop the duplication is tracked in #7.
21-
function hasAuthorizationHeader (headers) {
22-
if (!headers) return false;
23-
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
24-
return headers.has('authorization');
25-
}
26-
if (Array.isArray(headers)) {
27-
return headers.some((entry) =>
28-
Array.isArray(entry) && typeof entry[0] === 'string' &&
29-
entry[0].toLowerCase() === 'authorization'
30-
);
31-
}
32-
if (typeof headers === 'object') {
33-
for (const key of Object.keys(headers)) {
34-
if (key.toLowerCase() === 'authorization') return true;
35-
}
36-
}
37-
return false;
38-
}
39-
40-
// Per fetch spec, init.headers overrides Request.headers entirely — so
41-
// init.headers is authoritative when present. Only consult input.headers
42-
// when init omits the headers key.
43-
function fetchCallHasAuthorization (input, init) {
44-
if (init && Object.prototype.hasOwnProperty.call(init, 'headers')) {
45-
return hasAuthorizationHeader(init.headers);
46-
}
47-
if (typeof Request !== 'undefined' && input instanceof Request) {
48-
return hasAuthorizationHeader(input.headers);
49-
}
50-
return false;
51-
}
52-
53-
function setAuthorizationOnOptions (options, value) {
54-
options.headers = options.headers || {};
55-
if (typeof Headers !== 'undefined' && options.headers instanceof Headers) {
56-
options.headers.set('Authorization', value);
57-
} else if (Array.isArray(options.headers)) {
58-
const normalized = new Headers(options.headers);
59-
normalized.set('Authorization', value);
60-
options.headers = normalized;
61-
} else {
62-
for (const key of Object.keys(options.headers)) {
63-
if (key.toLowerCase() === 'authorization') delete options.headers[key];
64-
}
65-
options.headers['Authorization'] = value;
66-
}
67-
return options.headers;
68-
}
69-
70-
function normalizeFetchCall (input, init) {
71-
if (typeof Request !== 'undefined' && input instanceof Request) {
72-
return {
73-
url: input.url,
74-
method: init?.method || input.method || 'GET',
75-
body: init?.body
76-
};
77-
}
78-
return {
79-
url: typeof input === 'string' ? input : String(input),
80-
method: init?.method || 'GET',
81-
body: init?.body
82-
};
83-
}
84-
85-
// Helper to get auth header (will be async, but we'll handle that)
86-
let getAuthHeaderFn = null;
87-
88-
// Set up the async auth header function (will be defined below)
89-
function setAuthHeaderFn (fn) {
90-
getAuthHeaderFn = fn;
91-
}
92-
93-
// Intercept fetch - MUST replace immediately to catch all calls
94-
// This runs synchronously, so it catches fetch even if called immediately
95-
window.fetch = function (url, options = {}) {
96-
// Default-param `= {}` only applies for `undefined`; `fetch(url, null)`
97-
// passes null through. Coerce once so the rest of the wrapper can
98-
// assume an object.
99-
options = options || {};
100-
// Normalize once — handles fetch(url, init) and fetch(new Request(...))
101-
// so downstream signing sees the real URL/method, not "[object Request]".
102-
const { url: urlString, method, body } = normalizeFetchCall(url, options);
103-
console.log('[Podkey] 🔍 fetch() intercepted:', urlString, method);
104-
105-
// Respect an Authorization header the page already set — on either
106-
// options.headers or a Request input (e.g. Solid-OIDC DPoP). Overwriting
107-
// would re-identify the request as Podkey's NIP-98 and break the page's
108-
// own auth. If it fails with 401, the retry path below still injects
109-
// NIP-98. See issue #5.
110-
const pageSetAuth = fetchCallHasAuthorization(url, options);
111-
112-
// If we have the auth function, use it
113-
if (getAuthHeaderFn && !pageSetAuth) {
114-
console.log('[Podkey] ✅ Auth function ready, adding header...');
115-
const promise = (async () => {
116-
try {
117-
const authHeader = await getAuthHeaderFn(urlString, method, body);
118-
if (authHeader) {
119-
setAuthorizationOnOptions(options, authHeader);
120-
console.log('[Podkey] ✅ Added NIP-98 auth header');
121-
} else {
122-
console.log('[Podkey] ⚠️ No auth header returned (will retry on 401)');
123-
}
124-
} catch (e) {
125-
console.error('[Podkey] Error in fetch interceptor:', e);
126-
}
127-
return originalFetch.call(this, url, options);
128-
})();
129-
130-
// Handle 401 retry
131-
return promise.then(response => {
132-
if (response.status === 401 && getAuthHeaderFn) {
133-
console.log('[Podkey] 🔄 401 detected, retrying with auth...');
134-
return (async () => {
135-
try {
136-
const authHeader = await getAuthHeaderFn(urlString, method, body);
137-
if (authHeader) {
138-
const retryOptions = { ...options };
139-
setAuthorizationOnOptions(retryOptions, authHeader);
140-
console.log('[Podkey] 🔄 Retrying with NIP-98 auth...');
141-
const retryResponse = await originalFetch.call(this, url, retryOptions);
142-
if (retryResponse.status === 200 || retryResponse.status === 201) {
143-
console.log('[Podkey] ✅✅ NIP-98 auth retry successful!');
144-
} else {
145-
console.log('[Podkey] ⚠️ Retry still failed:', retryResponse.status);
146-
}
147-
return retryResponse;
148-
}
149-
} catch (e) {
150-
console.error('[Podkey] Error in 401 retry:', e);
151-
}
152-
return response;
153-
})();
154-
}
155-
return response;
156-
});
157-
}
158-
159-
// Fall-through branch: we're here because either the page set its own
160-
// Authorization (and we deliberately skipped initial injection) or the
161-
// auth function isn't wired up yet. Send the request as-is; on 401,
162-
// retry with NIP-98 — either immediately if ready, or after waiting
163-
// for setup (with a hard deadline so we don't hang forever).
164-
if (pageSetAuth) {
165-
console.log('[Podkey] ⏭️ Page already set Authorization — skipping initial injection');
166-
} else {
167-
console.log('[Podkey] ⚠️ Auth function not ready yet, making request...');
168-
}
169-
170-
const requestPromise = originalFetch.call(this, url, options);
171-
172-
const AUTH_READY_DEADLINE_MS = 5000;
173-
174-
// If we get a 401 and auth becomes available, retry
175-
return requestPromise.then(response => {
176-
if (response.status === 401) {
177-
console.log('[Podkey] 🔄 Got 401, checking if auth function is ready now...');
178-
// Wait for auth function to be ready, bounded by deadline. Every
179-
// async step below has an error handler so the outer promise is
180-
// guaranteed to settle (otherwise the caller would hang).
181-
return new Promise((resolve) => {
182-
const deadline = Date.now() + AUTH_READY_DEADLINE_MS;
183-
const checkAuth = () => {
184-
if (getAuthHeaderFn) {
185-
console.log('[Podkey] 🔄 Auth function now ready, retrying with NIP-98...');
186-
getAuthHeaderFn(urlString, method, body)
187-
.then(authHeader => {
188-
if (authHeader) {
189-
const retryOptions = { ...options };
190-
setAuthorizationOnOptions(retryOptions, authHeader);
191-
originalFetch.call(this, url, retryOptions)
192-
.then(resolve)
193-
.catch(err => {
194-
console.error('[Podkey] Retry fetch failed:', err);
195-
resolve(response);
196-
});
197-
} else {
198-
resolve(response);
199-
}
200-
})
201-
.catch(err => {
202-
console.error('[Podkey] Error getting auth header for retry:', err);
203-
resolve(response);
204-
});
205-
} else if (Date.now() >= deadline) {
206-
console.log('[Podkey] ⚠️ Auth function still not ready after deadline, giving up');
207-
resolve(response);
208-
} else {
209-
// Check again in 100ms
210-
setTimeout(checkAuth, 100);
211-
}
212-
};
213-
checkAuth();
214-
});
215-
}
216-
return response;
217-
});
218-
};
219-
220-
// Intercept XMLHttpRequest
221-
XMLHttpRequest.prototype.open = function (method, url, ...args) {
222-
this._podkeyMethod = method;
223-
this._podkeyUrl = url;
224-
this._podkeyHasPageAuth = false;
225-
return originalXHROpen.apply(this, [method, url, ...args]);
226-
};
227-
228-
// Track a page-set Authorization on XHR so send() can respect it.
229-
XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
230-
if (typeof name === 'string' && name.toLowerCase() === 'authorization') {
231-
this._podkeyHasPageAuth = true;
232-
}
233-
return originalXHRSetRequestHeader.apply(this, arguments);
234-
};
235-
236-
XMLHttpRequest.prototype.send = function (body) {
237-
// setRequestHeader must run before send(); defer originalXHRSend until
238-
// the header is applied (or skipped), else the async setRequestHeader
239-
// would fire after the request is already in-flight and throw
240-
// InvalidStateError.
241-
const runSend = () => originalXHRSend.apply(this, [body]);
242-
if (getAuthHeaderFn && this._podkeyUrl && !this._podkeyHasPageAuth) {
243-
getAuthHeaderFn(this._podkeyUrl, this._podkeyMethod, body)
244-
.then(authHeader => {
245-
if (authHeader) {
246-
originalXHRSetRequestHeader.call(this, 'Authorization', authHeader);
247-
}
248-
})
249-
.catch(e => {
250-
console.error('[Podkey] Error in XHR interceptor:', e);
251-
})
252-
.finally(runSend);
253-
} else {
254-
runSend();
255-
}
256-
};
257-
258-
// Expose setter for the auth function
259-
window.__podkey_setAuthFn = setAuthHeaderFn;
260-
261-
console.log('[Podkey] ✅ Synchronous interception setup complete');
262-
})();
263-
26411
// Inject NIP-98 interceptor FIRST (must run before any page code)
26512
const interceptorScript = document.createElement('script');
26613
interceptorScript.src = chrome.runtime.getURL('src/nip98-interceptor.js');
@@ -312,9 +59,12 @@ script.src = chrome.runtime.getURL('src/nostr-provider.js');
31259
script.onload = function () {
31360
this.remove();
31461
};
62+
script.onerror = function () {
63+
console.error('[Podkey] Failed to load nostr-provider.js');
64+
};
31565
(document.head || document.documentElement).appendChild(script);
31666

317-
// Listen for requests from the injected script
67+
// Listen for requests from the injected script (NIP-07 relay)
31868
window.addEventListener('podkey-request', async (event) => {
31969
const { id, type, ...data } = event.detail;
32070

@@ -370,53 +120,4 @@ window.addEventListener('podkey-request', async (event) => {
370120
}
371121
});
372122

373-
// NIP-98 auto-auth: Set up the async auth header function
374-
// This connects to the synchronous interceptor above
375-
(function setupAuthFunction () {
376-
console.log('[Podkey] Setting up NIP-98 auth function...');
377-
378-
async function getNip98AuthHeader (url, method, body) {
379-
try {
380-
const urlString = typeof url === 'string' ? url : url.toString();
381-
console.log('[Podkey] Requesting NIP-98 auth header for:', urlString, method);
382-
383-
const response = await chrome.runtime.sendMessage({
384-
type: 'CREATE_NIP98_AUTH_HEADER',
385-
url: urlString,
386-
method: method || 'GET',
387-
body: body
388-
});
389-
390-
if (chrome.runtime.lastError) {
391-
console.error('[Podkey] Error from background script:', chrome.runtime.lastError.message);
392-
return null;
393-
}
394-
395-
if (response) {
396-
console.log('[Podkey] ✅ Got NIP-98 auth header');
397-
} else {
398-
console.log('[Podkey] ❌ No NIP-98 auth header (origin not trusted, auto-sign disabled, or no keypair)');
399-
}
400-
401-
return response || null;
402-
} catch (error) {
403-
console.error('[Podkey] Error getting NIP-98 auth header:', error);
404-
return null;
405-
}
406-
}
407-
408-
// Connect the auth function to the synchronous interceptor
409-
if (window.__podkey_setAuthFn) {
410-
window.__podkey_setAuthFn(getNip98AuthHeader);
411-
console.log('[Podkey] ✅ Auth function connected');
412-
} else {
413-
console.error('[Podkey] ❌ Could not connect auth function - interceptor not ready');
414-
}
415-
})();
416-
417-
console.log('[Podkey] Content script loaded with NIP-98 auto-auth interception');
418-
419-
// Error handling for script injection
420-
script.onerror = function () {
421-
console.error('[Podkey] Failed to load nostr-provider.js');
422-
};
123+
console.log('[Podkey] Content script loaded');

0 commit comments

Comments
 (0)