Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change cloudflare issue request flow #292

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "privacy-pass",
"version": "3.0.0",
"version": "3.0.1",
"contributors": [
"Suphanat Chunhapanya <pop@cloudflare.com>",
"Armando Faz <armfazh@cloudflare.com>"
Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 24 additions & 1 deletion src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,28 @@ declare global {
}
}

declare let browser: any;

window.ACTIVE_TAB_ID = chrome.tabs.TAB_ID_NONE;
window.TABS = new Map<number, Tab>();

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);
Expand Down Expand Up @@ -57,10 +76,14 @@ chrome.webRequest.onBeforeRequest.addListener(handleBeforeRequest, { urls: ['<al
'blocking',
]);

const extraInfos = ['requestHeaders', 'blocking'];
if (getBrowser() === BROWSERS.CHROME) {
extraInfos.push('extraHeaders');
}
chrome.webRequest.onBeforeSendHeaders.addListener(
handleBeforeSendHeaders,
{ urls: ['<all_urls>'] },
['requestHeaders', 'blocking'],
extraInfos,
);

chrome.webRequest.onHeadersReceived.addListener(handleHeadersReceived, { urls: ['<all_urls>'] }, [
Expand Down
205 changes: 175 additions & 30 deletions src/background/providers/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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,
Expand All @@ -92,18 +104,105 @@ 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'],
},
},
};
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);
Expand All @@ -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', {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check that this request's Referer header will either not be present or contain the URL without the parameter?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what request do you mean? issue is a method that will send a request using axios which cannot have a Referer header at all.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant for the challenge solve request, and the Referer not being present is perfect. Thanks!

['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();
Expand All @@ -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();
Expand Down
Loading