Skip to content

Commit

Permalink
add static helper method: "getNormalizedFormData"
Browse files Browse the repository at this point in the history
During some real-world testing,
I noticed that Chrome doesn't always parse
'application/x-www-form-urlencoded' data in the POST body.

This new helper method is available to providers to normalize
the format of this data into a key/value hash object.

Furthermore, it will also parse 'application/json' data.
  • Loading branch information
warren-bank committed Jan 31, 2022
1 parent 29febe8 commit 4328fb1
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 77 deletions.
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.6.4",
"version": "3.6.5",
"private": true,
"contributors": [
"Suphanat Chunhapanya <[email protected]>",
Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "__MSG_appName__",
"description": "__MSG_appDescription__",
"version": "3.6.4",
"version": "3.6.5",
"manifest_version": 2,
"default_locale": "en",
"icons": {
Expand Down
10 changes: 7 additions & 3 deletions src/background/providers/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ describe('issuance', () => {
},
};

const flattenFormData: { [key: string]: string[] | string } = {
'h-captcha-response': 'body-param',
};

test('valid request', async () => {
const storage = new StorageMock();
const updateIcon = jest.fn();
Expand All @@ -137,7 +141,7 @@ describe('issuance', () => {
issueInfo = provider['issueInfo'];
expect(issueInfo!.requestId).toEqual(bodyDetails.requestId);
expect(issueInfo!.url).toEqual(bodyDetails.url);
expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData);
expect(issueInfo!.formData).toEqual(flattenFormData);

const headDetails: any = {
...validDetails,
Expand Down Expand Up @@ -199,7 +203,7 @@ describe('issuance', () => {
issueInfo = provider['issueInfo'];
expect(issueInfo!.requestId).toEqual(bodyDetails.requestId);
expect(issueInfo!.url).toEqual(bodyDetails.url);
expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData);
expect(issueInfo!.formData).toEqual(flattenFormData);

const headDetails: any = {
...validDetails,
Expand Down Expand Up @@ -271,7 +275,7 @@ describe('issuance', () => {
issueInfo = provider['issueInfo'];
expect(issueInfo!.requestId).toEqual(bodyDetails.requestId);
expect(issueInfo!.url).toEqual(bodyDetails.url);
expect(issueInfo!.formData).toEqual(bodyDetails.requestBody!.formData);
expect(issueInfo!.formData).toEqual(flattenFormData);

const headDetails: any = {
...validDetails,
Expand Down
54 changes: 20 additions & 34 deletions src/background/providers/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as voprf from '../crypto/voprf';

import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams } from './provider';
import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams, getNormalizedFormData } from './provider';
import { Storage } from '../storage';
import Token from '../token';
import axios from 'axios';
Expand All @@ -16,23 +16,22 @@ const COMMITMENT_URL: string =
'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json';

const ALL_ISSUING_CRITERIA: {
HOSTNAMES: QUALIFIED_HOSTNAMES;
PATHNAMES: QUALIFIED_PATHNAMES;
QUERY_PARAMS: QUALIFIED_PARAMS;
BODY_PARAMS: QUALIFIED_PARAMS;
HOSTNAMES: QUALIFIED_HOSTNAMES | void;
PATHNAMES: QUALIFIED_PATHNAMES | void;
QUERY_PARAMS: QUALIFIED_PARAMS | void;
BODY_PARAMS: QUALIFIED_PARAMS | void;
} = {
HOSTNAMES: {
exact : [DEFAULT_ISSUING_HOSTNAME],
contains: [`.${DEFAULT_ISSUING_HOSTNAME}`],
},
PATHNAMES: {
},
PATHNAMES: undefined,
QUERY_PARAMS: {
some: ['__cf_chl_captcha_tk__', '__cf_chl_managed_tk__'],
some: ['__cf_chl_captcha_tk__', '__cf_chl_managed_tk__'],
},
BODY_PARAMS: {
some: ['g-recaptcha-response', 'h-captcha-response', 'cf_captcha_kind'],
}
some: ['g-recaptcha-response', 'h-captcha-response', 'cf_captcha_kind'],
},
}

const VERIFICATION_KEY: string = `-----BEGIN PUBLIC KEY-----
Expand Down Expand Up @@ -110,26 +109,18 @@ export class CloudflareProvider extends Provider {
handleBeforeRequest(
details: chrome.webRequest.WebRequestBodyDetails,
): chrome.webRequest.BlockingResponse | void {
const url = new URL(details.url);
const formData: { [key: string]: string[] | string } = (details.requestBody && details.requestBody.formData)
? details.requestBody.formData
: {}
;

if (this.matchesIssuingBodyCriteria(details, url, formData)) {
this.issueInfo = { requestId: details.requestId, url: details.url, formData };

if (this.matchesIssuingBodyCriteria(details)) {
// do NOT cancel the request with captcha solution.
// note: "handleBeforeSendHeaders" will cancel this request if additional criteria are satisfied.
return { cancel: false };
}
}

private matchesIssuingBodyCriteria(
details: chrome.webRequest.WebRequestBodyDetails,
url: URL,
formData: { [key: string]: string[] | string },
details: chrome.webRequest.WebRequestBodyDetails,
): boolean {

// Only issue tokens for POST requests that contain data in body.
if (
(details.method.toUpperCase() !== 'POST' ) ||
Expand All @@ -139,6 +130,8 @@ export class CloudflareProvider extends Provider {
return false;
}

const url: URL = new URL(details.url);

// Only issue tokens to hosts belonging to the provider.
if (!isIssuingHostname(ALL_ISSUING_CRITERIA.HOSTNAMES, url)) {
return false;
Expand All @@ -149,11 +142,15 @@ export class CloudflareProvider extends Provider {
return false;
}

// Only issue tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria.
const formData: { [key: string]: string[] | string } = getNormalizedFormData(details, /* flatten= */ true);

// Only issue tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria.
if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) {
return false;
}

this.issueInfo = { requestId: details.requestId, url: details.url, formData };

return true;
}

Expand Down Expand Up @@ -321,19 +318,8 @@ export class CloudflareProvider extends Provider {
formData: { [key: string]: string[] | string },
): Promise<void> {
try {
// Normalize 'application/x-www-form-urlencoded' data parameters in POST body
const flattenFormData: { [key: string]: string[] | string } = {};
for (const key in formData) {
if (Array.isArray(formData[key]) && (formData[key].length === 1)) {
const [value] = formData[key];
flattenFormData[key] = value;
} else {
flattenFormData[key] = formData[key];
}
}

// Issue tokens.
const tokens = await this.issue(url, flattenFormData);
const tokens = await this.issue(url, formData);

// Store tokens.
const cached = this.getStoredTokens();
Expand Down
82 changes: 44 additions & 38 deletions src/background/providers/hcaptcha.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as voprf from '../crypto/voprf';

import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams } from './provider';
import { Provider, EarnedTokenCookie, Callbacks, QUALIFIED_HOSTNAMES, QUALIFIED_PATHNAMES, QUALIFIED_PARAMS, isIssuingHostname, isQualifiedPathname, areQualifiedQueryParams, areQualifiedBodyFormParams, getNormalizedFormData } from './provider';
import { Storage } from '../storage';
import Token from '../token';
import axios from 'axios';
Expand All @@ -15,10 +15,10 @@ const COMMITMENT_URL: string =
'https://raw.githubusercontent.com/privacypass/ec-commitments/master/commitments-p256.json';

const ALL_ISSUING_CRITERIA: {
HOSTNAMES: QUALIFIED_HOSTNAMES;
PATHNAMES: QUALIFIED_PATHNAMES;
QUERY_PARAMS: QUALIFIED_PARAMS;
BODY_PARAMS: QUALIFIED_PARAMS;
HOSTNAMES: QUALIFIED_HOSTNAMES | void;
PATHNAMES: QUALIFIED_PATHNAMES | void;
QUERY_PARAMS: QUALIFIED_PARAMS | void;
BODY_PARAMS: QUALIFIED_PARAMS | void;
} = {
HOSTNAMES: {
exact : [DEFAULT_ISSUING_HOSTNAME],
Expand All @@ -28,17 +28,16 @@ const ALL_ISSUING_CRITERIA: {
contains: ['/checkcaptcha'],
},
QUERY_PARAMS: {
some: ['s=00000000-0000-0000-0000-000000000000'],
some: ['s=00000000-0000-0000-0000-000000000000'],
},
BODY_PARAMS: {
}
BODY_PARAMS: undefined,
}

const ALL_REDEMPTION_CRITERIA: {
HOSTNAMES: QUALIFIED_HOSTNAMES;
PATHNAMES: QUALIFIED_PATHNAMES;
QUERY_PARAMS: QUALIFIED_PARAMS;
BODY_PARAMS: QUALIFIED_PARAMS;
HOSTNAMES: QUALIFIED_HOSTNAMES | void;
PATHNAMES: QUALIFIED_PATHNAMES | void;
QUERY_PARAMS: QUALIFIED_PARAMS | void;
BODY_PARAMS: QUALIFIED_PARAMS | void;
} = {
HOSTNAMES: {
exact : [DEFAULT_ISSUING_HOSTNAME],
Expand All @@ -48,11 +47,11 @@ const ALL_REDEMPTION_CRITERIA: {
contains: ['/getcaptcha'],
},
QUERY_PARAMS: {
some: ['s!=00000000-0000-0000-0000-000000000000'],
some: ['s!=00000000-0000-0000-0000-000000000000'],
},
BODY_PARAMS: {
every: ['sitekey!=00000000-0000-0000-0000-000000000000', 'motionData', 'host!=www.hcaptcha.com'],
}
every: ['sitekey!=00000000-0000-0000-0000-000000000000', 'motionData', 'host!=www.hcaptcha.com'],
},
}

const VERIFICATION_KEY: string = `-----BEGIN PUBLIC KEY-----
Expand Down Expand Up @@ -128,33 +127,23 @@ export class HcaptchaProvider extends Provider {
handleBeforeRequest(
details: chrome.webRequest.WebRequestBodyDetails,
): chrome.webRequest.BlockingResponse | void {
const url = new URL(details.url);
const formData: { [key: string]: string[] | string } = (details.requestBody && details.requestBody.formData)
? details.requestBody.formData
: {}
;

if (this.matchesIssuingCriteria(details, url, formData)) {
this.issueInfo = { requestId: details.requestId, url: details.url };

if (this.matchesIssuingCriteria(details)) {
// do NOT cancel the request with captcha solution.
return { cancel: false };
}

if (this.matchesRedemptionCriteria(details, url, formData)) {
this.redeemInfo = { requestId: details.requestId };

if (this.matchesRedemptionCriteria(details)) {
// do NOT cancel the request to generate a new captcha.
// note: "handleBeforeSendHeaders" will add request headers to embed a token.
return { cancel: false };
}
}

private matchesIssuingCriteria(
details: chrome.webRequest.WebRequestBodyDetails,
url: URL,
formData: { [key: string]: string[] | string }
details: chrome.webRequest.WebRequestBodyDetails,
): boolean {

// Only issue tokens for POST requests that contain data in body.
if (
(details.method.toUpperCase() !== 'POST' ) ||
Expand All @@ -164,6 +153,8 @@ export class HcaptchaProvider extends Provider {
return false;
}

const url: URL = new URL(details.url);

// Only issue tokens to hosts belonging to the provider.
if (!isIssuingHostname(ALL_ISSUING_CRITERIA.HOSTNAMES, url)) {
return false;
Expand All @@ -179,19 +170,25 @@ export class HcaptchaProvider extends Provider {
return false;
}

// Only issue tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria.
if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) {
return false;
// conditionally short-circuit an expensive operation
if (ALL_ISSUING_CRITERIA.BODY_PARAMS !== undefined) {
const formData: { [key: string]: string[] | string } = getNormalizedFormData(details);

// Only issue tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria.
if (!areQualifiedBodyFormParams(ALL_ISSUING_CRITERIA.BODY_PARAMS, formData)) {
return false;
}
}

this.issueInfo = { requestId: details.requestId, url: details.url };

return true;
}

private matchesRedemptionCriteria(
details: chrome.webRequest.WebRequestBodyDetails,
url: URL,
formData: { [key: string]: string[] | string }
details: chrome.webRequest.WebRequestBodyDetails,
): boolean {

// Only redeem tokens for POST requests that contain data in body.
if (
(details.method.toUpperCase() !== 'POST' ) ||
Expand All @@ -201,6 +198,8 @@ export class HcaptchaProvider extends Provider {
return false;
}

const url: URL = new URL(details.url);

// Only redeem tokens to hosts belonging to the provider.
if (!isIssuingHostname(ALL_REDEMPTION_CRITERIA.HOSTNAMES, url)) {
return false;
Expand All @@ -216,11 +215,18 @@ export class HcaptchaProvider extends Provider {
return false;
}

// Only redeem tokens when 'application/x-www-form-urlencoded' data parameters in POST body pass defined criteria.
if (!areQualifiedBodyFormParams(ALL_REDEMPTION_CRITERIA.BODY_PARAMS, formData)) {
return false;
// conditionally short-circuit an expensive operation
if (ALL_REDEMPTION_CRITERIA.BODY_PARAMS !== undefined) {
const formData: { [key: string]: string[] | string } = getNormalizedFormData(details);

// Only redeem tokens when 'application/x-www-form-urlencoded' or 'application/json' data parameters in POST body pass defined criteria.
if (!areQualifiedBodyFormParams(ALL_REDEMPTION_CRITERIA.BODY_PARAMS, formData)) {
return false;
}
}

this.redeemInfo = { requestId: details.requestId };

return true;
}

Expand Down
Loading

0 comments on commit 4328fb1

Please sign in to comment.