Skip to content

Catch all recaptcha errors #7203

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

Merged
merged 7 commits into from
Apr 12, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/shaggy-zebras-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/app-check': patch
---

Catch all ReCAPTCHA errors and, if caught, prevent App Check from making a request to the exchange endpoint.
10 changes: 10 additions & 0 deletions packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ describe('api', () => {
let storageReadStub: SinonStub;
let storageWriteStub: SinonStub;

function setRecaptchaSuccess(isSuccess: boolean = true): void {
getStateReference(app).reCAPTCHAState!.succeeded = isSuccess;
}

beforeEach(() => {
app = getFullApp();
storageReadStub = stub(storage, 'readTokenFromStorage').resolves(undefined);
Expand Down Expand Up @@ -291,6 +295,8 @@ describe('api', () => {
isTokenAutoRefreshEnabled: true
});

setRecaptchaSuccess(true);

expect(getStateReference(app).tokenObservers.length).to.equal(1);

const fakeRecaptchaToken = 'fake-recaptcha-token';
Expand Down Expand Up @@ -335,6 +341,8 @@ describe('api', () => {
isTokenAutoRefreshEnabled: true
});

setRecaptchaSuccess(true);

expect(getStateReference(app).tokenObservers.length).to.equal(1);

const fakeRecaptchaToken = 'fake-recaptcha-token';
Expand Down Expand Up @@ -391,6 +399,8 @@ describe('api', () => {
isTokenAutoRefreshEnabled: false
});

setRecaptchaSuccess(true);

expect(getStateReference(app).tokenObservers.length).to.equal(0);

const fakeRecaptchaToken = 'fake-recaptcha-token';
Expand Down
74 changes: 55 additions & 19 deletions packages/app-check/src/internal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ describe('internal api', () => {
let storageReadStub: SinonStub;
let storageWriteStub: SinonStub;

function stubGetRecaptchaToken(
token: string = fakeRecaptchaToken,
isSuccess: boolean = true
): SinonStub {
getStateReference(app).reCAPTCHAState!.succeeded = isSuccess;

return stub(reCAPTCHA, 'getToken').returns(Promise.resolve(token));
}

beforeEach(() => {
app = getFullApp();
storageReadStub = stub(storage, 'readTokenFromStorage').resolves(undefined);
Expand Down Expand Up @@ -104,9 +113,7 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
Promise.resolve(fakeRecaptchaToken)
);
const reCAPTCHASpy = stubGetRecaptchaToken();
const exchangeTokenStub: SinonStub = stub(
client,
'exchangeToken'
Expand All @@ -127,9 +134,8 @@ describe('internal api', () => {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
});

const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
Promise.resolve(fakeRecaptchaToken)
);
const reCAPTCHASpy = stubGetRecaptchaToken();

const exchangeTokenStub: SinonStub = stub(
client,
'exchangeToken'
Expand All @@ -151,9 +157,7 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
Promise.resolve(fakeRecaptchaToken)
);
const reCAPTCHASpy = stubGetRecaptchaToken();

const error = new Error('oops, something went wrong');
stub(client, 'exchangeToken').returns(Promise.reject(error));
Expand All @@ -171,6 +175,26 @@ describe('internal api', () => {
errorStub.restore();
});

it('resolves with a dummy token and an error if recaptcha failed', async () => {
const errorStub = stub(console, 'error');
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

const reCAPTCHASpy = stubGetRecaptchaToken('', false);
Copy link
Contributor

Choose a reason for hiding this comment

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

Should the first parameter here, the token parameter, be related to formatDummyToken(defaultTokenErrorData)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, that's the recaptcha token. The flow in App Check (when using recaptcha, the default attestation provider) is

  1. The client requests a recaptcha token
  2. recaptcha returns a recaptcha token
  3. The client takes the recaptcha token and sends it to the App Check exchange endpoint
  4. The App Check exchange endpoint returns an App Check token

In this test, step 3 is being interrupted, the recaptcha token was no good (it was '' as stubbed here), so it won't bother to send anything to the exchange endpoint and will just create a dummy App Check token locally, which is returned by getToken() (the function that covers the whole exchange logic).

Copy link
Contributor

Choose a reason for hiding this comment

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

Ahhh. thanks so much for the detailed response!

const exchangeTokenStub = stub(client, 'exchangeToken');

const token = await getToken(appCheck as AppCheckService);

expect(reCAPTCHASpy).to.be.called;
expect(exchangeTokenStub).to.not.be.called;
expect(token.token).to.equal(formatDummyToken(defaultTokenErrorData));
expect(errorStub.args[0][1].message).to.include(
AppCheckError.RECAPTCHA_ERROR
);
errorStub.restore();
});

it('notifies listeners using cached token', async () => {
storageReadStub.resolves(fakeCachedAppCheckToken);
const appCheck = initializeAppCheck(app, {
Expand Down Expand Up @@ -213,7 +237,7 @@ describe('internal api', () => {
isTokenAutoRefreshEnabled: true
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve(fakeRecaptchaAppCheckToken)
);
Expand Down Expand Up @@ -247,7 +271,7 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').rejects('exchange error');
const listener1 = spy();
const errorFn1 = spy();
Expand All @@ -271,7 +295,7 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve(fakeRecaptchaAppCheckToken)
);
Expand Down Expand Up @@ -324,7 +348,7 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve(fakeRecaptchaAppCheckToken)
);
Expand Down Expand Up @@ -365,7 +389,7 @@ describe('internal api', () => {
token: fakeRecaptchaAppCheckToken
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve({
token: 'new-recaptcha-app-check-token',
Expand All @@ -390,7 +414,7 @@ describe('internal api', () => {
cachedTokenPromise: undefined
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve({
token: 'new-recaptcha-app-check-token',
Expand Down Expand Up @@ -431,7 +455,7 @@ describe('internal api', () => {
cachedTokenPromise: undefined
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
let count = 0;
stub(client, 'exchangeToken').callsFake(
() =>
Expand Down Expand Up @@ -485,7 +509,7 @@ describe('internal api', () => {
}
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(
Promise.resolve({
token: 'new-recaptcha-app-check-token',
Expand Down Expand Up @@ -532,7 +556,7 @@ describe('internal api', () => {
issuedAtTimeMillis: 0
};

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(Promise.resolve(freshToken));

expect(await getToken(appCheck as AppCheckService)).to.deep.equal({
Expand All @@ -556,7 +580,7 @@ describe('internal api', () => {
token: fakeRecaptchaAppCheckToken
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stubGetRecaptchaToken();
stub(client, 'exchangeToken').returns(Promise.reject(new Error('blah')));

const tokenResult = await getToken(appCheck as AppCheckService, true);
Expand Down Expand Up @@ -589,6 +613,7 @@ describe('internal api', () => {
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
stubGetRecaptchaToken();
const warnStub = stub(logger, 'warn');
stub(client, 'exchangeToken').returns(
Promise.reject(
Expand All @@ -615,6 +640,7 @@ describe('internal api', () => {
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
stubGetRecaptchaToken();
const warnStub = stub(logger, 'warn');
stub(client, 'exchangeToken').returns(
Promise.reject(
Expand Down Expand Up @@ -765,6 +791,8 @@ describe('internal api', () => {
})
);

stubGetRecaptchaToken();

addTokenListener(
appCheck as AppCheckService,
ListenerType.INTERNAL,
Expand Down Expand Up @@ -799,6 +827,8 @@ describe('internal api', () => {
}
});

stubGetRecaptchaToken();

const fakeListener: AppCheckTokenListener = stub();

const fakeExchange = stub(client, 'exchangeToken').returns(
Expand Down Expand Up @@ -838,6 +868,8 @@ describe('internal api', () => {
}
});

stubGetRecaptchaToken();

const fakeListener: AppCheckTokenListener = stub();

const fakeExchange = stub(client, 'exchangeToken').returns(
Expand Down Expand Up @@ -865,6 +897,8 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});

stubGetRecaptchaToken();
setInitialState(app, {
...getStateReference(app),
token: {
Expand Down Expand Up @@ -905,6 +939,8 @@ describe('internal api', () => {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});

stubGetRecaptchaToken();
setInitialState(app, {
...getStateReference(app),
token: {
Expand Down
21 changes: 14 additions & 7 deletions packages/app-check/src/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import { stub, useFakeTimers } from 'sinon';
import { expect } from 'chai';
import { FirebaseError } from '@firebase/util';
import { AppCheckError } from './errors';
import { clearState } from './state';
import {
clearState,
DEFAULT_STATE,
getStateReference,
setInitialState
} from './state';
import { deleteApp, FirebaseApp } from '@firebase/app';

describe('ReCaptchaV3Provider', () => {
Expand All @@ -34,6 +39,7 @@ describe('ReCaptchaV3Provider', () => {
beforeEach(() => {
clock = useFakeTimers();
app = getFullApp();
setInitialState(app, DEFAULT_STATE);
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
stub(reCAPTCHA, 'getToken').returns(
Promise.resolve('fake-recaptcha-token')
Expand All @@ -46,40 +52,40 @@ describe('ReCaptchaV3Provider', () => {
return deleteApp(app);
});
it('getToken() gets a token from the exchange endpoint', async () => {
const app = getFullApp();
const provider = new ReCaptchaV3Provider('fake-site-key');
stub(client, 'exchangeToken').resolves({
token: 'fake-exchange-token',
issuedAtTimeMillis: 0,
expireTimeMillis: 10
});
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
const token = await provider.getToken();
expect(token.token).to.equal('fake-exchange-token');
});
it('getToken() throttles 1d on 403', async () => {
const app = getFullApp();
const provider = new ReCaptchaV3Provider('fake-site-key');
stub(client, 'exchangeToken').rejects(
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
httpStatus: 403
})
);
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
await expect(provider.getToken()).to.be.rejectedWith('1d');
// Wait 10s and try again to see if wait time string decreases.
clock.tick(10000);
await expect(provider.getToken()).to.be.rejectedWith('23h');
});
it('getToken() throttles exponentially on 503', async () => {
const app = getFullApp();
const provider = new ReCaptchaV3Provider('fake-site-key');
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
httpStatus: 503
})
);
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
await expect(provider.getToken()).to.be.rejectedWith('503');
expect(exchangeTokenStub).to.be.called;
exchangeTokenStub.resetHistory();
Expand Down Expand Up @@ -120,6 +126,7 @@ describe('ReCaptchaEnterpriseProvider', () => {
beforeEach(() => {
clock = useFakeTimers();
app = getFullApp();
setInitialState(app, DEFAULT_STATE);
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
stub(reCAPTCHA, 'getToken').returns(
Promise.resolve('fake-recaptcha-token')
Expand All @@ -132,40 +139,40 @@ describe('ReCaptchaEnterpriseProvider', () => {
return deleteApp(app);
});
it('getToken() gets a token from the exchange endpoint', async () => {
const app = getFullApp();
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
stub(client, 'exchangeToken').resolves({
token: 'fake-exchange-token',
issuedAtTimeMillis: 0,
expireTimeMillis: 10
});
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
const token = await provider.getToken();
expect(token.token).to.equal('fake-exchange-token');
});
it('getToken() throttles 1d on 403', async () => {
const app = getFullApp();
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
stub(client, 'exchangeToken').rejects(
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
httpStatus: 403
})
);
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
await expect(provider.getToken()).to.be.rejectedWith('1d');
// Wait 10s and try again to see if wait time string decreases.
clock.tick(10000);
await expect(provider.getToken()).to.be.rejectedWith('23h');
});
it('getToken() throttles exponentially on 503', async () => {
const app = getFullApp();
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
httpStatus: 503
})
);
provider.initialize(app);
getStateReference(app).reCAPTCHAState!.succeeded = true;
await expect(provider.getToken()).to.be.rejectedWith('503');
expect(exchangeTokenStub).to.be.called;
exchangeTokenStub.resetHistory();
Expand Down
Loading