diff --git a/.Jules/sentinel.md b/.Jules/sentinel.md index 981886a..21bd7de 100644 --- a/.Jules/sentinel.md +++ b/.Jules/sentinel.md @@ -2,3 +2,7 @@ **Vulnerability:** The extension's Content Security Policy (CSP) in `manifest.json` lacked explicit `default-src 'none'` and broad network constraints, relying solely on script and object restrictions. **Learning:** A permissive CSP allows unexpected resource loading and potential data exfiltration if an XSS vulnerability occurs. A strict whitelist (`default-src 'none'`) provides a robust defense-in-depth layer. **Prevention:** Always define a strict CSP for extensions, setting `default-src 'none'` and explicitly allowing only required origins (e.g., `connect-src`). +## 2026-08-27 - Prevent token leakage via open redirects +**Vulnerability:** The `jFetch` wrapper did not constrain HTTP redirects when sending requests with sensitive credentials (API tokens), making it susceptible to accidental token leakage via cross-origin open redirects. +**Learning:** When implementing network wrappers (e.g., `fetch`) that transmit sensitive credentials like API tokens, conditionally setting `redirect: 'error'` or `redirect: 'manual'` in the fetch options when credentials are provided prevents accidental token leakage via cross-origin open redirects without breaking legitimate API endpoints that rely on standard HTTP redirects. +**Prevention:** Always restrict redirect behavior when sending authorization headers in custom fetch wrappers. diff --git a/background.js b/background.js index 61728be..6495fb0 100644 --- a/background.js +++ b/background.js @@ -63,6 +63,7 @@ async function jFetch(url, options = {}) { if (typeof token !== 'string') throw new Error('Token must be a string') if (/[\r\n]/.test(token)) throw new Error('Invalid token: contains newline') headers.Authorization = `token ${token}` + rest.redirect = rest.redirect || 'error' } const controller = new AbortController() diff --git a/tests/background.test.js b/tests/background.test.js index ad10dfa..f661796 100644 --- a/tests/background.test.js +++ b/tests/background.test.js @@ -1597,6 +1597,28 @@ describe('jFetch', () => { assert.strictEqual(capturedHeaders.Authorization, 'token valid-token') }) + it('should set redirect to error when token is provided and redirect is undefined', async () => { + const { sandbox } = setupEnvironment() + let capturedOptions = null + sandbox.fetch = async (_url, options) => { + capturedOptions = options + return { ok: true } + } + await sandbox.test_jFetch('https://api.github.com/test', { token: 'secret-token' }) + assert.strictEqual(capturedOptions.redirect, 'error') + }) + + it('should preserve existing redirect option when token is provided', async () => { + const { sandbox } = setupEnvironment() + let capturedOptions = null + sandbox.fetch = async (_url, options) => { + capturedOptions = options + return { ok: true } + } + await sandbox.test_jFetch('https://api.github.com/test', { token: 'secret-token', redirect: 'manual' }) + assert.strictEqual(capturedOptions.redirect, 'manual') + }) + it('should throw an error for HTTP 500 status code', async () => { const { sandbox } = setupEnvironment() sandbox.fetch = async () => ({ diff --git a/tests/security.test.js b/tests/security.test.js index 6be189e..c566bf1 100644 --- a/tests/security.test.js +++ b/tests/security.test.js @@ -162,7 +162,10 @@ function setupEnvironment(initialTabs = {}) { const sandbox = { chrome: chromeMock, - fetch: async () => ({ ok: true, json: async () => [], text: async () => ")]}'\n\n4\n[[]]" }), + fetch: async (url, options) => { + chromeMock.lastFetch = { url, options } + return { ok: true, json: async () => [], text: async () => ")]}'\n\n4\n[[]]" } + }, setTimeout, Date, Promise, @@ -312,6 +315,18 @@ describe('jFetch SSRF Security', () => { message: /Security Error: Refusing to send GitHub token to non-GitHub origin/ }) }) + + it('should set redirect to error when token is provided and redirect is undefined', async () => { + const { sandbox } = setupEnvironment() + await sandbox.jFetch('https://api.github.com/test', { token: 'secret-token' }) + assert.strictEqual(sandbox.chrome.lastFetch.options.redirect, 'error') + }) + + it('should preserve existing redirect option when token is provided', async () => { + const { sandbox } = setupEnvironment() + await sandbox.jFetch('https://api.github.com/test', { token: 'secret-token', redirect: 'manual' }) + assert.strictEqual(sandbox.chrome.lastFetch.options.redirect, 'manual') + }) }) describe('getTabConfig Path Traversal Security', () => {