Skip to content

Commit

Permalink
Catch all recaptcha errors (#7203)
Browse files Browse the repository at this point in the history
  • Loading branch information
hsubox76 authored Apr 12, 2023
1 parent 833ca90 commit 8c44d58
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 33 deletions.
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);
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

0 comments on commit 8c44d58

Please sign in to comment.