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

Improve decodeBase64() to throw on invalid input rather than silently accept it #7019

Merged
merged 8 commits into from
Feb 11, 2023
7 changes: 7 additions & 0 deletions .changeset/popular-apples-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/firestore': patch
'@firebase/util': patch
'firebase': patch
---

Modify base64 decoding logic to throw on invalid input, rather than silently truncating it.
8 changes: 8 additions & 0 deletions common/api-review/util.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export function createSubscribe<T>(executor: Executor<T>, onNoObservers?: Execut
// @public
export const decode: (token: string) => DecodedToken;

// Warning: (ae-missing-release-tag) "DecodeBase64StringError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class DecodeBase64StringError extends Error {
// (undocumented)
readonly name = "DecodeBase64StringError";
}

// Warning: (ae-missing-release-tag) "deepCopy" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
Expand Down
15 changes: 14 additions & 1 deletion packages/firestore/src/platform/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,26 @@
* limitations under the License.
*/

import { Base64DecodeError } from '../util/base64_decode_error';

// This file is only used under ts-node.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const platform = require(`./${process.env.TEST_PLATFORM ?? 'node'}/base64`);

/** Converts a Base64 encoded string to a binary string. */
export function decodeBase64(encoded: string): string {
return platform.decodeBase64(encoded);
const decoded = platform.decodeBase64(encoded);

// A quick correctness check that the input string was valid base64.
// See https://stackoverflow.com/questions/13378815/base64-length-calculation.
// This is done because node and rn will not always throw an error if the
// input is an invalid base64 string (e.g. "A===").
const expectedEncodedLength = 4 * Math.ceil(decoded.length / 3);
if (encoded.length !== expectedEncodedLength) {
throw new Base64DecodeError('Invalid base64 string');
}

return decoded;
}

/** Converts a binary string to a Base64 encoded string. */
Expand Down
12 changes: 11 additions & 1 deletion packages/firestore/src/platform/browser/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@
* limitations under the License.
*/

import { Base64DecodeError } from '../../util/base64_decode_error';

/** Converts a Base64 encoded string to a binary string. */
export function decodeBase64(encoded: string): string {
return atob(encoded);
try {
return atob(encoded);
} catch (e) {
if (e instanceof DOMException) {
throw new Base64DecodeError('Invalid base64 string: ' + e);
} else {
throw e;
}
}
}

/** Converts a binary string to a Base64 encoded string. */
Expand Down
26 changes: 18 additions & 8 deletions packages/firestore/src/platform/rn/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,31 @@
* limitations under the License.
*/

import { base64 } from '@firebase/util';
import { base64, DecodeBase64StringError } from '@firebase/util';

import { Base64DecodeError } from '../../util/base64_decode_error';

// WebSafe uses a different URL-encoding safe alphabet that doesn't match
// the encoding used on the backend.
const WEB_SAFE = false;

/** Converts a Base64 encoded string to a binary string. */
export function decodeBase64(encoded: string): string {
return String.fromCharCode.apply(
null,
// We use `decodeStringToByteArray()` instead of `decodeString()` since
// `decodeString()` returns Unicode strings, which doesn't match the values
// returned by `atob()`'s Latin1 representation.
base64.decodeStringToByteArray(encoded, WEB_SAFE)
);
try {
return String.fromCharCode.apply(
null,
// We use `decodeStringToByteArray()` instead of `decodeString()` since
// `decodeString()` returns Unicode strings, which doesn't match the values
// returned by `atob()`'s Latin1 representation.
base64.decodeStringToByteArray(encoded, WEB_SAFE)
);
} catch (e) {
if (e instanceof DecodeBase64StringError) {
throw new Base64DecodeError('Invalid base64 string: ' + e);
Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't followed these all the way up the chain but will they eventually get wrapped in a FirestoreError?

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, the error won't be re-thrown as a FirestoreError. The specific use case is a future optimization that involves decoding base64. If, for some reason, we get invalid base64 from the backend then we will just ignore it and disable the optimization. We don't expect to ever get invalid base64, but it could theoretically happen and if it ever does we don't want to crash.

The problem was that it was previously not possible to reliably detect an invalid base64 string. This PR fixes that by having the new Base64DecodeError always thrown if an invalid base64 string is given as input. The future code will catch this exception and use it to disable the optimization.

} else {
throw e;
}
}
}

/** Converts a binary string to a Base64 encoded string. */
Expand Down
23 changes: 23 additions & 0 deletions packages/firestore/src/util/base64_decode_error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/**
* An error encountered while decoding base64 string.
*/
export class Base64DecodeError extends Error {
readonly name = 'Base64DecodeError';
}
2 changes: 1 addition & 1 deletion packages/firestore/test/lite/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ describe('DocumentSnapshot', () => {

it('returns Bytes', () => {
return withTestDocAndInitialData(
{ bytes: Bytes.fromBase64String('aa') },
{ bytes: Bytes.fromBase64String('aa==') },
async docRef => {
const docSnap = await getDoc(docRef);
const bytes = docSnap.get('bytes');
Expand Down
9 changes: 8 additions & 1 deletion packages/util/src/crypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export const base64: Base64 = {
++i;

if (byte1 == null || byte2 == null || byte3 == null || byte4 == null) {
throw Error();
throw new DecodeBase64StringError();
}

const outByte1 = (byte1 << 2) | (byte2 >> 4);
Expand Down Expand Up @@ -333,6 +333,13 @@ export const base64: Base64 = {
}
};

/**
* An error encountered while decoding base64 string.
*/
export class DecodeBase64StringError extends Error {
readonly name = 'DecodeBase64StringError';
}

/**
* URL-safe base64 encoding
*/
Expand Down