diff --git a/package.json b/package.json index fb4fe06e..913baeb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "privacy-pass", - "version": "3.0.0", + "version": "3.0.1", "contributors": [ "Suphanat Chunhapanya ", "Armando Faz " diff --git a/public/manifest.json b/public/manifest.json index 3a5edf12..a6676770 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,7 +2,7 @@ "name": "Privacy Pass", "manifest_version": 2, "description": "Client support for Privacy Pass anonymous authorization protocol.", - "version": "3.0.0", + "version": "3.0.1", "icons": { "32": "icons/32/gold.png", "48": "icons/48/gold.png", diff --git a/src/background/index.ts b/src/background/index.ts index 46c79005..9c279e4c 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -21,9 +21,28 @@ declare global { } } +declare let browser: any; + window.ACTIVE_TAB_ID = chrome.tabs.TAB_ID_NONE; window.TABS = new Map(); +const BROWSERS = { + CHROME: 'Chrome', + FIREFOX: 'Firefox', + EDGE: 'Edge', +} as const; +type BROWSERS = typeof BROWSERS[keyof typeof BROWSERS]; + +function getBrowser(): BROWSERS { + if (typeof chrome !== 'undefined') { + if (typeof browser !== 'undefined') { + return BROWSERS.FIREFOX; + } + return BROWSERS.CHROME; + } + return BROWSERS.EDGE; +} + /* Listeners for navigator */ chrome.tabs.onActivated.addListener(handleActivated); @@ -57,10 +76,14 @@ chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest, { urls: [''] }, - ['requestHeaders', 'blocking'], + extraInfos, ); chrome.webRequest.onHeadersReceived.addListener(handleHeadersReceived, { urls: [''] }, [ diff --git a/src/background/providers/cloudflare.test.ts b/src/background/providers/cloudflare.test.ts index 94b3de00..a5198bd9 100644 --- a/src/background/providers/cloudflare.test.ts +++ b/src/background/providers/cloudflare.test.ts @@ -57,17 +57,32 @@ test('getBadgeText', () => { }); /* - * The issuance involves handleBeforeRequest listener. + * The issuance involves handleBeforeRequest and handleBeforeSendHeaders + * listeners. In handleBeforeRequest listener, * 1. Firstly, the listener check if the request looks like the one that we * should send an issuance request. - * 2. If it passes the check, the listener returns the cancel command to - * cancel the request. If not, it returns nothing and let the request - * continue. - * 3. At the same time the listener returns, it calls a private method + * 2. If it passes the check, The listener sets "issueInfo" property which + * includes the request id and the form data of the request. The property + * will be used by handleBeforeSendHeaders again to issue the tokens. If not, + * it returns nothing and let the request continue. + * + * In handleBeforeSendHeaders, + * 1. The listener will check if the provided request id matches the + * request id in "issueInfo". If so, it means that we are issuing the tokens. + * If not, it returns nothing and let the request continue. + * 2. If it passes the check, the listener extract the form data from + * "issueInfo" clears the "issueInfo" property because "issueInfo" is used + * already. If not, it returns nothing and let the request continue. + * 3. The listener tries to look for the Referer header to get + * the (not PP) token from __cf_chl_tk query param in the Referer url. + * 4. The listener returns the cancel command to cancel the request. + * 5. At the same time the listener returns, it calls a private method * "issue" to send an issuance request to the server and the method return - * an array of issued tokens. - * 4. The listener stored the issued tokens in the storage. - * 5. The listener reloads the tab to get the proper web page for the tab.); + * an array of issued tokens. In the issuance request, the body will be the + * form data extracted from "issueInfo" earlier and also include the + * __cf_chl_f_tk query param with the token it got from Step 3 (if any). + * 6. The listener stored the issued tokens in the storage. + * 7. The listener reloads the tab to get the proper web page for the tab. */ describe('issuance', () => { describe('handleBeforeRequest', () => { @@ -77,12 +92,9 @@ describe('issuance', () => { const navigateUrl = jest.fn(); const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); - const tokens = [new Token(), new Token(), new Token()]; - const issue = jest.fn(async () => { - return tokens; - }); + const issue = jest.fn(async () => []); provider['issue'] = issue; - const url = 'https://captcha.website/?__cf_chl_captcha_tk__=query-param'; + const url = 'https://captcha.website'; const details = { method: 'POST', url, @@ -92,6 +104,47 @@ describe('issuance', () => { tabId: 1, type: 'xmlhttprequest' as chrome.webRequest.ResourceType, timeStamp: 1, + requestBody: { + formData: { + ['h-captcha-response']: ['body-param'], + ['cf_captcha_kind']: ['body-param'], + }, + }, + }; + const result = await provider.handleBeforeRequest(details); + expect(result).toBeUndefined(); + expect(issue).not.toHaveBeenCalled(); + expect(navigateUrl).not.toHaveBeenCalled(); + + const issueInfo = provider['issueInfo']; + expect(issueInfo!.requestId).toEqual(details.requestId); + expect(issueInfo!.formData).toStrictEqual({ + ['h-captcha-response']: 'body-param', + ['cf_captcha_kind']: 'body-param', + }); + }); + + /* + * The request is invalid only if the body has both + * 'h-captcha-response' and 'cf_captcha_kind' params. + */ + test('invalid request', async () => { + const storage = new StorageMock(); + const updateIcon = jest.fn(); + const navigateUrl = jest.fn(); + + const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); + const issue = jest.fn(async () => []); + provider['issue'] = issue; + const details = { + method: 'GET', + url: 'https://cloudflare.com/', + requestId: 'xxx', + frameId: 1, + parentFrameId: 1, + tabId: 1, + type: 'xmlhttprequest' as chrome.webRequest.ResourceType, + timeStamp: 1, requestBody: { formData: { ['h-captcha-response']: ['body-param'], @@ -99,11 +152,57 @@ describe('issuance', () => { }, }; const result = await provider.handleBeforeRequest(details); - expect(result).toEqual({ cancel: true }); + expect(result).toBeUndefined(); + expect(issue).not.toHaveBeenCalled(); + expect(navigateUrl).not.toHaveBeenCalled(); + }); + }); + + describe('handleBeforeSendHeaders', () => { + test('with issueInfo with Referer header', async () => { + const storage = new StorageMock(); + const updateIcon = jest.fn(); + const navigateUrl = jest.fn(); + + const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); + const tokens = [new Token(), new Token(), new Token()]; + const issue = jest.fn(async () => { + return tokens; + }); + provider['issue'] = issue; + const issueInfo = { + requestId: 'xxx', + formData: { + ['h-captcha-response']: 'body-param', + ['cf_captcha_kind']: 'body-param', + }, + }; + provider['issueInfo'] = issueInfo; + const details = { + method: 'POST', + url: 'https://captcha.website', + requestId: 'xxx', + frameId: 1, + parentFrameId: 1, + tabId: 1, + type: 'xmlhttprequest' as chrome.webRequest.ResourceType, + timeStamp: 1, + requestHeaders: [ + { + name: 'Referer', + value: 'https://captcha.website/?__cf_chl_tk=token', + }, + ], + }; + const result = await provider.handleBeforeSendHeaders(details); + expect(result).toStrictEqual({ cancel: true }); + const newIssueInfo = provider['issueInfo']; + expect(newIssueInfo).toBeNull(); expect(issue.mock.calls.length).toBe(1); - expect(issue).toHaveBeenCalledWith(url, { + expect(issue).toHaveBeenCalledWith('https://captcha.website/?__cf_chl_f_tk=token', { ['h-captcha-response']: 'body-param', + ['cf_captcha_kind']: 'body-param', }); expect(navigateUrl.mock.calls.length).toBe(1); @@ -116,17 +215,58 @@ describe('issuance', () => { ); }); - /* - * The request is invalid if any of the followings is true: - * 1. It has no url param of any of the followings: - * a. '__cf_chl_captcha_tk__' - * b. '__cf_chl_managed_tk__' - * 2. It has no body param of any of the followings: - * a. 'g-recaptcha-response' - * b. 'h-captcha-response' - * c. 'cf_captcha_kind' - */ - test('invalid request', async () => { + test('with issueInfo without Referer header', async () => { + const storage = new StorageMock(); + const updateIcon = jest.fn(); + const navigateUrl = jest.fn(); + + const provider = new CloudflareProvider(storage, { updateIcon, navigateUrl }); + const tokens = [new Token(), new Token(), new Token()]; + const issue = jest.fn(async () => { + return tokens; + }); + provider['issue'] = issue; + const issueInfo = { + requestId: 'xxx', + formData: { + ['h-captcha-response']: 'body-param', + ['cf_captcha_kind']: 'body-param', + }, + }; + provider['issueInfo'] = issueInfo; + const details = { + method: 'POST', + url: 'https://captcha.website/?__cf_chl_f_tk=token', + requestId: 'xxx', + frameId: 1, + parentFrameId: 1, + tabId: 1, + type: 'xmlhttprequest' as chrome.webRequest.ResourceType, + timeStamp: 1, + requestHeaders: [], + }; + const result = await provider.handleBeforeSendHeaders(details); + expect(result).toStrictEqual({ cancel: true }); + const newIssueInfo = provider['issueInfo']; + expect(newIssueInfo).toBeNull(); + + expect(issue.mock.calls.length).toBe(1); + expect(issue).toHaveBeenCalledWith('https://captcha.website/?__cf_chl_f_tk=token', { + ['h-captcha-response']: 'body-param', + ['cf_captcha_kind']: 'body-param', + }); + + expect(navigateUrl.mock.calls.length).toBe(1); + expect(navigateUrl).toHaveBeenCalledWith('https://captcha.website/'); + + // Expect the tokens are added. + const storedTokens = provider['getStoredTokens'](); + expect(storedTokens.map((token) => token.toString())).toEqual( + tokens.map((token) => token.toString()), + ); + }); + + test('without issueInfo', async () => { const storage = new StorageMock(); const updateIcon = jest.fn(); const navigateUrl = jest.fn(); @@ -135,17 +275,22 @@ describe('issuance', () => { const issue = jest.fn(async () => []); provider['issue'] = issue; const details = { - method: 'GET', - url: 'https://cloudflare.com/', + method: 'POST', + url: 'https://captcha.website', requestId: 'xxx', frameId: 1, parentFrameId: 1, tabId: 1, type: 'xmlhttprequest' as chrome.webRequest.ResourceType, timeStamp: 1, - requestBody: {}, + requestHeaders: [ + { + name: 'Referer', + value: 'https://captcha.website/?__cf_chl_tk=token', + }, + ], }; - const result = await provider.handleBeforeRequest(details); + const result = await provider.handleBeforeSendHeaders(details); expect(result).toBeUndefined(); expect(issue).not.toHaveBeenCalled(); expect(navigateUrl).not.toHaveBeenCalled(); diff --git a/src/background/providers/cloudflare.ts b/src/background/providers/cloudflare.ts index 2f9f1fe1..5eb226a7 100644 --- a/src/background/providers/cloudflare.ts +++ b/src/background/providers/cloudflare.ts @@ -13,12 +13,14 @@ const ISSUANCE_BODY_PARAM_NAME = 'blinded-tokens'; const COMMITMENT_URL = 'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json'; -const QUALIFIED_QUERY_PARAMS = ['__cf_chl_captcha_tk__', '__cf_chl_managed_tk__']; -const QUALIFIED_BODY_PARAMS = ['g-recaptcha-response', 'h-captcha-response', 'cf_captcha_kind']; +const QUALIFIED_BODY_PARAMS = ['h-captcha-response', 'cf_captcha_kind']; const CHL_BYPASS_SUPPORT = 'cf-chl-bypass'; const DEFAULT_ISSUING_HOSTNAME = 'captcha.website'; +const REFERER_QUERY_PARAM = '__cf_chl_tk'; +const QUERY_PARAM = '__cf_chl_f_tk'; + const VERIFICATION_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExf0AftemLr0YSz5odoj3eJv6SkOF VcH7NNb2xwdEz6Pxm44tvovEl/E+si8hdIDVg1Ys+cbaWwP0jYJW3ygv+Q== @@ -31,11 +33,17 @@ interface RedeemInfo { token: Token; } +interface IssueInfo { + requestId: string; + formData: { [key: string]: string[] | string }; +} + export class CloudflareProvider implements Provider { static readonly ID: number = 1; private callbacks: Callbacks; private storage: Storage; + private issueInfo: IssueInfo | null; private redeemInfo: RedeemInfo | null; constructor(storage: Storage, callbacks: Callbacks) { @@ -45,6 +53,7 @@ export class CloudflareProvider implements Provider { this.callbacks = callbacks; this.storage = storage; + this.issueInfo = null; this.redeemInfo = null; } @@ -191,44 +200,85 @@ export class CloudflareProvider implements Provider { handleBeforeSendHeaders( details: chrome.webRequest.WebRequestHeadersDetails, ): chrome.webRequest.BlockingResponse | void { - if (this.redeemInfo === null || details.requestId !== this.redeemInfo.requestId) { - return; + // If we suppose to redeem a token with this request + if (this.redeemInfo !== null && details.requestId === this.redeemInfo.requestId) { + const url = new URL(details.url); + + const token = this.redeemInfo.token; + // Clear the redeem info to indicate that we are already redeeming the token. + this.redeemInfo = null; + + const key = token.getMacKey(); + const binding = voprf.createRequestBinding(key, [ + voprf.getBytesFromString(url.hostname), + voprf.getBytesFromString(details.method + ' ' + url.pathname), + ]); + + const contents = [ + voprf.getBase64FromBytes(token.getInput()), + binding, + voprf.getBase64FromString(JSON.stringify(voprf.defaultECSettings)), + ]; + const redemption = btoa(JSON.stringify({ type: 'Redeem', contents })); + + const headers = details.requestHeaders ?? []; + headers.push({ name: 'challenge-bypass-token', value: redemption }); + + this.callbacks.updateIcon(this.getBadgeText()); + + return { + requestHeaders: headers, + }; } - const url = new URL(details.url); - - const token = this.redeemInfo!.token; - // Clear the redeem info to indicate that we are already redeeming the token. - this.redeemInfo = null; - - const key = token.getMacKey(); - const binding = voprf.createRequestBinding(key, [ - voprf.getBytesFromString(url.hostname), - voprf.getBytesFromString(details.method + ' ' + url.pathname), - ]); - - const contents = [ - voprf.getBase64FromBytes(token.getInput()), - binding, - voprf.getBase64FromString(JSON.stringify(voprf.defaultECSettings)), - ]; - const redemption = btoa(JSON.stringify({ type: 'Redeem', contents })); - - const headers = details.requestHeaders ?? []; - headers.push({ name: 'challenge-bypass-token', value: redemption }); - - this.callbacks.updateIcon(this.getBadgeText()); + // If we suppose to issue tokens with this request + if (this.issueInfo !== null && details.requestId === this.issueInfo.requestId) { + const formData = this.issueInfo.formData; + // Clear the issue info to indicate that we are already issuing tokens. + this.issueInfo = null; + + // We are supposed to also send a Referer header in the issuance request, if there is + // any in the original request. But the browsers don't allow us to send a Referer + // header according to https://xhr.spec.whatwg.org/#dom-xmlhttprequest-setrequestheader + // So we need to extract the token from the Referer header and send it in the query + // param __cf_chl_f_tk instead. (Note that this token is not a Privacy Pass token. + let token: string | null = null; + if (details.requestHeaders !== undefined) { + details.requestHeaders.forEach((header) => { + // Filter only for Referrer header. + if (header.name === 'Referer' && header.value !== undefined) { + const url = new URL(header.value); + token = url.searchParams.get(REFERER_QUERY_PARAM); + } + }); + } - return { - requestHeaders: headers, - }; + (async () => { + const url = new URL(details.url); + if (token !== null) { + url.searchParams.append(QUERY_PARAM, token); + } + + // Issue tokens. + const tokens = await this.issue(url.href, formData); + // Store tokens. + const cached = this.getStoredTokens(); + this.setStoredTokens(cached.concat(tokens)); + + this.callbacks.navigateUrl(`${url.origin}${url.pathname}`); + })(); + + // TODO I tried to use redirectUrl with data URL or text/html and text/plain but it didn't work, so I continue + // cancelling the request. However, it seems that we can use image/* except image/svg+html. Let's figure how to + // use image data URL later. + // https://blog.mozilla.org/security/2017/11/27/blocking-top-level-navigations-data-urls-firefox-59/ + return { cancel: true }; + } } handleBeforeRequest( details: chrome.webRequest.WebRequestBodyDetails, ): chrome.webRequest.BlockingResponse | void { - const url = new URL(details.url); - if ( details.requestBody === null || details.requestBody === undefined || @@ -237,13 +287,10 @@ export class CloudflareProvider implements Provider { return; } - const hasQueryParams = QUALIFIED_QUERY_PARAMS.some((param) => { - return url.searchParams.has(param); - }); - const hasBodyParams = QUALIFIED_BODY_PARAMS.some((param) => { + const hasBodyParams = QUALIFIED_BODY_PARAMS.every((param) => { return details.requestBody !== null && param in details.requestBody.formData!; }); - if (!hasQueryParams || !hasBodyParams) { + if (!hasBodyParams) { return; } @@ -257,21 +304,7 @@ export class CloudflareProvider implements Provider { } } - (async () => { - // Issue tokens. - const tokens = await this.issue(details.url, flattenFormData); - // Store tokens. - const cached = this.getStoredTokens(); - this.setStoredTokens(cached.concat(tokens)); - - this.callbacks.navigateUrl(`${url.origin}${url.pathname}`); - })(); - - // TODO I tried to use redirectUrl with data URL or text/html and text/plain but it didn't work, so I continue - // cancelling the request. However, it seems that we can use image/* except image/svg+html. Let's figure how to - // use image data URL later. - // https://blog.mozilla.org/security/2017/11/27/blocking-top-level-navigations-data-urls-firefox-59/ - return { cancel: true }; + this.issueInfo = { requestId: details.requestId, formData: flattenFormData }; } handleHeadersReceived(