diff --git a/.eslintrc b/.eslintrc index fb73f31..48f6750 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ ], "rules": { "semi": [2, "always"], + "no-trailing-spaces": [0], "no-multi-spaces": [1, { "exceptions": { "VariableDeclarator": true diff --git a/README.md b/README.md index de66547..f32db94 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Current Cloudflare implementation requires browser to respect the timeout of 5 s ## Kudos to contributors - [Dwayne](https://github.com/pro-src) + - [drdokk](https://github.com/drdokk) - [Cole Faust](https://github.com/Colecf) - [Jeongbong Seo](https://github.com/jngbng) - [Mike van Rossum](https://github.com/askmike) diff --git a/index.js b/index.js index 36c7b5b..7d5d7e7 100644 --- a/index.js +++ b/index.js @@ -251,9 +251,23 @@ function onCloudflareResponse (options, response, body) { onRequestComplete(options, response, body); } +function detectRecaptchaVersion (body) { + // New version > Dec 2019 + if (/__cf_chl_captcha_tk__=(.*)/i.test(body)) { // Test for ver2 first, as it also has ver2 fields + return 'ver2'; + // Old version < Dec 2019 + } else if (body.indexOf('why_captcha') !== -1 || /cdn-cgi\/l\/chk_captcha/i.test(body)) { + return 'ver1'; + } + + return false; +} + function validateResponse (options, response, body) { // Finding captcha - if (body.indexOf('why_captcha') !== -1 || /cdn-cgi\/l\/chk_captcha/i.test(body)) { + // Old version < Dec 2019 + const recaptchaVer = detectRecaptchaVersion(body); + if (recaptchaVer) { // Convenience boolean response.isCaptcha = true; throw new CaptchaError('captcha', options, response); @@ -383,11 +397,13 @@ function onChallenge (options, response, body) { // Parses the reCAPTCHA form and hands control over to the user function onCaptcha (options, response, body) { + const recaptchaVer = detectRecaptchaVersion(body); + const isRecaptchaVer2 = recaptchaVer === 'ver2'; const callback = options.callback; // UDF that has the responsibility of returning control back to cloudscraper const handler = options.onCaptcha; // The form data to send back to Cloudflare - const payload = { /* s, g-re-captcha-response */ }; + const payload = { /* r|s, g-re-captcha-response */ }; let cause; let match; @@ -399,7 +415,18 @@ function onCaptcha (options, response, body) { } const form = match[1]; + let siteKey; + let rayId; // only for ver 2 + + if (isRecaptchaVer2) { + match = body.match(/\sdata-ray=["']?([^\s"'<>&]+)/); + if (!match) { + cause = 'Unable to find cloudflare ray id'; + return callback(new ParserError(cause, options, response)); + } + rayId = match[1]; + } match = body.match(/\sdata-sitekey=["']?([^\s"'<>&]+)/); if (match) { @@ -434,9 +461,24 @@ function onCaptcha (options, response, body) { response.captcha = { siteKey, uri: response.request.uri, - form: payload + form: payload, + version: recaptchaVer }; + if (isRecaptchaVer2) { + response.rayId = rayId; + + match = body.match(/id="challenge-form" action="(.+?)" method="(.+?)"/); + if (!match) { + cause = 'Challenge form action and method extraction failed'; + return callback(new ParserError(cause, options, response)); + } + response.captcha.formMethod = match[2]; + match = match[1].match(/\/(.*)/); + response.captcha.formActionUri = match[0]; + payload.id = rayId; + } + Object.defineProperty(response.captcha, 'url', { configurable: true, enumerable: false, @@ -465,7 +507,7 @@ function onCaptcha (options, response, body) { } // Sanity check - if (!payload.s) { + if (!payload.s && !payload.r) { cause = 'Challenge form is missing secret input'; return callback(new ParserError(cause, options, response)); } @@ -506,19 +548,34 @@ function onCaptcha (options, response, body) { function onSubmitCaptcha (options, response) { const callback = options.callback; const uri = response.request.uri; + const isRecaptchaVer2 = response.captcha.version === 'ver2'; if (!response.captcha.form['g-recaptcha-response']) { const cause = 'Form submission without g-recaptcha-response'; return callback(new CaptchaError(cause, options, response)); } - options.method = 'GET'; - options.qs = response.captcha.form; + if (isRecaptchaVer2) { + options.qs = { + __cf_chl_captcha_tk__: response.captcha.formActionUri.match(/__cf_chl_captcha_tk__=(.*)/)[1] + }; + + options.form = response.captcha.form; + } else { + options.qs = response.captcha.form; + } + + options.method = response.captcha.formMethod || 'GET'; + // Prevent reusing the headers object to simplify unit testing. options.headers = Object.assign({}, options.headers); // Use the original uri as the referer and to construct the form action. options.headers.Referer = uri.href; - options.uri = uri.protocol + '//' + uri.host + '/cdn-cgi/l/chk_captcha'; + if (isRecaptchaVer2) { + options.uri = uri.protocol + '//' + uri.host + response.captcha.formActionUri; + } else { + options.uri = uri.protocol + '//' + uri.host + '/cdn-cgi/l/chk_captcha'; + } performRequest(options, false); } diff --git a/test/fixtures/cf_recaptcha_01_12_2019.html b/test/fixtures/cf_recaptcha_01_12_2019.html new file mode 100644 index 0000000..1e0d5bd --- /dev/null +++ b/test/fixtures/cf_recaptcha_01_12_2019.html @@ -0,0 +1,148 @@ + + + + + + +Attention Required! | Cloudflare + + + + + + + + + + + + + + + + + + + +
+ +
+
+

One more step

+

Please complete the security check to access www.cloudflare.com

+
+ +
+
+
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + + +
+
+
+
+
+ +
+
+
+

Why do I have to complete a CAPTCHA?

+ +

Completing the CAPTCHA proves you are a human and gives you temporary access to the web property.

+
+ +
+

What can I do to prevent this in the future?

+ + +

If you are on a personal connection, like at home, you can run an anti-virus scan on your device to make sure it is not infected with malware.

+ +

If you are at an office or shared network, you can ask the network administrator to run a scan across the network looking for misconfigured or infected devices.

+ + +

Another way to prevent getting this page in the future is to use Privacy Pass. You may need to download version 2.0 now from the Chrome Web Store.

+ + +
+
+
+ + + + + +
+
+ + + + + + + + + \ No newline at end of file diff --git a/test/test-captcha.js b/test/test-captcha.js index b537232..3421daa 100644 --- a/test/test-captcha.js +++ b/test/test-captcha.js @@ -64,111 +64,224 @@ describe('Cloudscraper', function () { expect(promise).to.be.rejectedWith(errors.CaptchaError).and.notify(done); }); }); - - for (let stage = 0; stage < 4; stage++) { - const desc = { - 0: 'should resolve reCAPTCHA (version as on 10.04.2019) when user calls captcha.submit()', - 1: 'should callback with an error if user calls captcha.submit(error)', - 2: 'should resolve reCAPTCHA (version as on 10.04.2019) when the onCaptcha promise resolves', - 3: 'should callback with an error if the onCaptcha promise is rejected' - }; - - // Run this test 4 times - it(desc[stage], function (done) { - const secret = '6b132d85d185a8255f2451d48fe6a8bee7154ea2-1555377580-1800-AQ1azEkeDOnQP5ByOpwUU/RdbKrmMwHYpkaenRvjPXtB0w8Vbjn/Ceg62tfpp/lT799kjDLEMMuDkEMqQ7iO51kniWCQm00BQvDGl+D0h/WvXDWO96YXOUD3qrqUTuzO7QbUOinc8y8kedvOQkr4c0o='; - const siteKey = '6LfBixYUAAAAABhdHynFUIMA_sa4s-XsJvnjtgB0'; - const expectedError = new Error('anti-captcha failed!'); - - helper.router - .get('/test', function (req, res) { - res.sendCaptcha('cf_recaptcha_15_04_2019.html'); - }) - .get('/cdn-cgi/l/chk_captcha', function (req, res) { - res.send(requestedPage); + describe('reCAPTCHA (version as on 10.04.2019)', () => { + for (let stage = 0; stage < 4; stage++) { + const desc = { + 0: 'should resolve when user calls captcha.submit()', + 1: 'should callback with an error if user calls captcha.submit(error)', + 2: 'should resolve when the onCaptcha promise resolves', + 3: 'should callback with an error if the onCaptcha promise is rejected' + }; + + // Run this test 4 times + it(desc[stage], function (done) { + const secret = '6b132d85d185a8255f2451d48fe6a8bee7154ea2-1555377580-1800-AQ1azEkeDOnQP5ByOpwUU/RdbKrmMwHYpkaenRvjPXtB0w8Vbjn/Ceg62tfpp/lT799kjDLEMMuDkEMqQ7iO51kniWCQm00BQvDGl+D0h/WvXDWO96YXOUD3qrqUTuzO7QbUOinc8y8kedvOQkr4c0o='; + const siteKey = '6LfBixYUAAAAABhdHynFUIMA_sa4s-XsJvnjtgB0'; + const expectedError = new Error('anti-captcha failed!'); + + helper.router + .get('/test', function (req, res) { + res.sendCaptcha('cf_recaptcha_15_04_2019.html'); + }) + .get('/cdn-cgi/l/chk_captcha', function (req, res) { + res.send(requestedPage); + }); + + const onCaptcha = sinon.spy(function (options, response, body) { + expect(options).to.be.an('object'); + expect(response).to.be.instanceof(http.IncomingMessage); + expect(body).to.be.a('string'); + + sinon.assert.match(response, { + isCloudflare: true, + isHTML: true, + isCaptcha: true, + captcha: sinon.match.object + }); + + sinon.assert.match(response.captcha, { + url: uri, // <-- Deprecated + uri: sinon.match.same(response.request.uri), + form: { s: secret }, + siteKey: siteKey, + submit: sinon.match.func + }); + + // Simulate what the user should do here + response.captcha.form['g-recaptcha-response'] = 'foobar'; + + switch (stage) { + case 0: + // User green lights form submission + response.captcha.submit(); + break; + case 1: + // User reports an error when solving the reCAPTCHA + response.captcha.submit(expectedError); + break; + case 2: + // User green lights form submission by resolving the returned promise + return Promise.resolve(); + case 3: + // User reports an error by rejecting the returned promise + return Promise.reject(expectedError); + } }); - - const onCaptcha = sinon.spy(function (options, response, body) { - expect(options).to.be.an('object'); - expect(response).to.be.instanceof(http.IncomingMessage); - expect(body).to.be.a('string'); - - sinon.assert.match(response, { - isCloudflare: true, - isHTML: true, - isCaptcha: true, - captcha: sinon.match.object + + const firstParams = helper.extendParams({ onCaptcha, uri }); + const secondParams = helper.extendParams({ + onCaptcha, + method: 'GET', + uri: helper.resolve('/cdn-cgi/l/chk_captcha'), + headers: { + Referer: uri + }, + qs: { + s: secret, + 'g-recaptcha-response': 'foobar' + } }); - - sinon.assert.match(response.captcha, { - url: uri, // <-- Deprecated - uri: sinon.match.same(response.request.uri), - form: { s: secret }, - siteKey: siteKey, - submit: sinon.match.func + + const options = { onCaptcha, uri }; + + const promise = cloudscraper.get(options, function (error, response, body) { + switch (stage) { + case 0: + case 2: + expect(error).to.be.null; + + expect(onCaptcha).to.be.calledOnce; + + expect(Request).to.be.calledTwice; + expect(Request.firstCall).to.be.calledWithExactly(firstParams); + expect(Request.secondCall).to.be.calledWithExactly(secondParams); + + expect(body).to.be.equal(requestedPage); + expect(promise).to.eventually.equal(requestedPage).and.notify(done); + break; + case 1: + case 3: + expect(error).to.be.instanceOf(errors.CaptchaError); + expect(error.error).to.be.an('error'); + expect(error).to.have.property('errorType', 1); + expect(error.message).to.include(expectedError.message); + expect(promise).to.be.rejectedWith(errors.CaptchaError).and.notify(done); + break; + } }); - - // Simulate what the user should do here - response.captcha.form['g-recaptcha-response'] = 'foobar'; - - switch (stage) { - case 0: - // User green lights form submission - response.captcha.submit(); - break; - case 1: - // User reports an error when solving the reCAPTCHA - response.captcha.submit(expectedError); - break; - case 2: - // User green lights form submission by resolving the returned promise - return Promise.resolve(); - case 3: - // User reports an error by rejecting the returned promise - return Promise.reject(expectedError); - } - }); - - const firstParams = helper.extendParams({ onCaptcha, uri }); - const secondParams = helper.extendParams({ - onCaptcha, - method: 'GET', - uri: helper.resolve('/cdn-cgi/l/chk_captcha'), - headers: { - Referer: uri - }, - qs: { - s: secret, - 'g-recaptcha-response': 'foobar' - } }); + } + }); - const options = { onCaptcha, uri }; - - const promise = cloudscraper.get(options, function (error, response, body) { - switch (stage) { - case 0: - case 2: - expect(error).to.be.null; - - expect(onCaptcha).to.be.calledOnce; + describe('reCAPTCHA (version as on 01.12.2019)', () => { + for (let stage = 0; stage < 4; stage++) { + const desc = { + 0: 'should resolve when user calls captcha.submit()', + 1: 'should callback with an error if user calls captcha.submit(error)', + 2: 'should resolve when the onCaptcha promise resolves', + 3: 'should callback with an error if the onCaptcha promise is rejected' + }; + + // Run this test 4 times + it(desc[stage], function (done) { + const secret = '0bd666f149acf02bbc05bba3b1bb'; + const siteKey = '6LfBixYUAAAAABhdHynFUIMA_sa4s-XsJvnjtgB0'; + const rayId = '53dfe8147d2a9e73'; + const expectedError = new Error('anti-captcha failed!'); + + helper.router + .get('/test', function (req, res) { + res.sendCaptcha('cf_recaptcha_01_12_2019.html'); + }) + .post('/', function (req, res) { + res.send(requestedPage); + }); + + const onCaptcha = sinon.spy(function (options, response, body) { + expect(options).to.be.an('object'); + expect(response).to.be.instanceof(http.IncomingMessage); + expect(body).to.be.a('string'); + + sinon.assert.match(response, { + isCloudflare: true, + isHTML: true, + isCaptcha: true, + captcha: sinon.match.object + }); + + sinon.assert.match(response.captcha, { + url: uri, // <-- Deprecated + uri: sinon.match.same(response.request.uri), + form: { r: secret, id: rayId }, + siteKey: siteKey, + submit: sinon.match.func + }); + + // Simulate what the user should do here + response.captcha.form['g-recaptcha-response'] = 'foobar'; + + switch (stage) { + case 0: + // User green lights form submission + response.captcha.submit(); + break; + case 1: + // User reports an error when solving the reCAPTCHA + response.captcha.submit(expectedError); + break; + case 2: + // User green lights form submission by resolving the returned promise + return Promise.resolve(); + case 3: + // User reports an error by rejecting the returned promise + return Promise.reject(expectedError); + } + }); + + const firstParams = helper.extendParams({ onCaptcha, uri }); + const secondParams = helper.extendParams({ + onCaptcha, + method: 'POST', + uri: helper.resolve('/?__cf_chl_captcha_tk__=e8844bdff35ae5e'), + qs: { __cf_chl_captcha_tk__: 'e8844bdff35ae5e' }, + headers: { + Referer: helper.resolve('/test') + }, + form: { + r: secret, + id: rayId, + 'g-recaptcha-response': 'foobar' + } + }); - expect(Request).to.be.calledTwice; - expect(Request.firstCall).to.be.calledWithExactly(firstParams); - expect(Request.secondCall).to.be.calledWithExactly(secondParams); + const options = { onCaptcha, uri }; + + const promise = cloudscraper.get(options, function (error, response, body) { + switch (stage) { + case 0: + case 2: + expect(error).to.be.null; + + expect(onCaptcha).to.be.calledOnce; + + expect(Request).to.be.calledTwice; + expect(Request.firstCall).to.be.calledWithExactly(firstParams); + expect(Request.secondCall).to.be.calledWithExactly(secondParams); - expect(body).to.be.equal(requestedPage); - expect(promise).to.eventually.equal(requestedPage).and.notify(done); - break; - case 1: - case 3: - expect(error).to.be.instanceOf(errors.CaptchaError); - expect(error.error).to.be.an('error'); - expect(error).to.have.property('errorType', 1); - expect(error.message).to.include(expectedError.message); - expect(promise).to.be.rejectedWith(errors.CaptchaError).and.notify(done); - break; - } + expect(body).to.be.equal(requestedPage); + expect(promise).to.eventually.equal(requestedPage).and.notify(done); + break; + case 1: + case 3: + expect(error).to.be.instanceOf(errors.CaptchaError); + expect(error.error).to.be.an('error'); + expect(error).to.have.property('errorType', 1); + expect(error.message).to.include(expectedError.message); + expect(promise).to.be.rejectedWith(errors.CaptchaError).and.notify(done); + break; + } + }); }); - }); - } + }; + }); });