Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 10 additions & 4 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder {
*/
constructor(protected app: App, protected version: string, protected tenantId: string, emHost?: string) {
super(app, version, emHost);
if (!validator.isTenantId(tenantId)) {
throw new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
}
if (emHost) {
this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT, {
host: emHost
Expand Down Expand Up @@ -419,7 +422,7 @@ function validateCreateEditRequest(request: any, writeOperationType: WriteOperat
}
}
if (typeof request.tenantId !== 'undefined' &&
!validator.isNonEmptyString(request.tenantId)) {
!validator.isTenantId(request.tenantId)) {
throw new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
}
// For any invalid parameter, use the external key name in the error description.
Expand Down Expand Up @@ -2212,7 +2215,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler {
* @returns A promise that resolves with the tenant information.
*/
public getTenant(tenantId: string): Promise<TenantServerResponse> {
if (!validator.isNonEmptyString(tenantId)) {
if (!validator.isTenantId(tenantId)) {
return Promise.reject(new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID));
}
return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_TENANT, {}, { tenantId })
Expand Down Expand Up @@ -2262,7 +2265,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler {
* @returns A promise that resolves when the tenant is deleted.
*/
public deleteTenant(tenantId: string): Promise<void> {
if (!validator.isNonEmptyString(tenantId)) {
if (!validator.isTenantId(tenantId)) {
return Promise.reject(new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID));
}
return this.invokeRequestHandler(this.authResourceUrlBuilder, DELETE_TENANT, undefined, { tenantId })
Expand Down Expand Up @@ -2298,7 +2301,7 @@ export class AuthRequestHandler extends AbstractAuthRequestHandler {
* @returns A promise that resolves with the modified tenant object.
*/
public updateTenant(tenantId: string, tenantOptions: UpdateTenantRequest): Promise<TenantServerResponse> {
if (!validator.isNonEmptyString(tenantId)) {
if (!validator.isTenantId(tenantId)) {
return Promise.reject(new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID));
}
try {
Expand Down Expand Up @@ -2335,6 +2338,9 @@ export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler {
*/
constructor(app: App, private readonly tenantId: string, emHost?: string | null) {
super(app, emHost);
if (!validator.isTenantId(tenantId)) {
throw new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
}
}

/**
Expand Down
29 changes: 10 additions & 19 deletions src/auth/auth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,7 +1178,9 @@ export class SAMLConfig implements SAMLAuthProviderConfig {
* @returns Whether the provider ID corresponds to a SAML provider.
*/
public static isProviderId(providerId: any): providerId is string {
return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0;
return validator.isNonEmptyString(providerId) &&
providerId.startsWith('saml.') &&
validator.isSafeUrlSegment(providerId);
}

/**
Expand Down Expand Up @@ -1215,15 +1217,8 @@ export class SAMLConfig implements SAMLAuthProviderConfig {
}
}
// Required fields.
if (validator.isNonEmptyString(options.providerId)) {
if (options.providerId.indexOf('saml.') !== 0) {
throw new FirebaseAuthError(
authClientErrorCode.INVALID_PROVIDER_ID,
'"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".',
);
}
} else if (!ignoreMissingFields) {
// providerId is required and not provided correctly.
if (!(ignoreMissingFields && typeof options.providerId === 'undefined') &&
!SAMLConfig.isProviderId(options.providerId)) {
throw new FirebaseAuthError(
!options.providerId ? authClientErrorCode.MISSING_PROVIDER_ID : authClientErrorCode.INVALID_PROVIDER_ID,
'"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".',
Expand Down Expand Up @@ -1429,7 +1424,9 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
* @returns Whether the provider ID corresponds to an OIDC provider.
*/
public static isProviderId(providerId: any): providerId is string {
return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0;
return validator.isNonEmptyString(providerId) &&
providerId.startsWith('oidc.') &&
validator.isSafeUrlSegment(providerId);
}

/**
Expand Down Expand Up @@ -1468,14 +1465,8 @@ export class OIDCConfig implements OIDCAuthProviderConfig {
}
}
// Required fields.
if (validator.isNonEmptyString(options.providerId)) {
if (options.providerId.indexOf('oidc.') !== 0) {
throw new FirebaseAuthError(
!options.providerId ? authClientErrorCode.MISSING_PROVIDER_ID : authClientErrorCode.INVALID_PROVIDER_ID,
'"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".',
);
}
} else if (!ignoreMissingFields) {
if (!(ignoreMissingFields && typeof options.providerId === 'undefined') &&
!OIDCConfig.isProviderId(options.providerId)) {
throw new FirebaseAuthError(
!options.providerId ? authClientErrorCode.MISSING_PROVIDER_ID : authClientErrorCode.INVALID_PROVIDER_ID,
'"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".',
Expand Down
5 changes: 4 additions & 1 deletion src/auth/tenant-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export class TenantAwareAuth extends BaseAuth {
* @internal
*/
constructor(app: App, tenantId: string, emHost?: string | null) {
if (!validator.isTenantId(tenantId)) {
throw new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
}
const emIsSet = emHost !== undefined;
super(app, new TenantAwareAuthRequestHandler(
app, tenantId, emHost), createFirebaseTokenGenerator(
Expand Down Expand Up @@ -175,7 +178,7 @@ export class TenantManager {
* @returns The `TenantAwareAuth` instance corresponding to this tenant identifier.
*/
public authForTenant(tenantId: string): TenantAwareAuth {
if (!validator.isNonEmptyString(tenantId)) {
if (!validator.isTenantId(tenantId)) {
throw new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
}
if (typeof this.tenantsMap[tenantId] === 'undefined') {
Expand Down
25 changes: 25 additions & 0 deletions src/utils/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,28 @@ export function isTaskId(taskId: any): boolean {
const VALID_TASK_ID_REGEX = /^[A-Za-z0-9_-]+$/;
return VALID_TASK_ID_REGEX.test(taskId);
}

/**
* Validates that a string is a safe single URL path segment containing only
* unreserved URI characters (RFC 3986) and not ending with a dot.
*
* @param segment - The string to validate.
* @returns Whether the string is a safe URL path segment.
*/
export function isSafeUrlSegment(segment: any): segment is string {
// Reject trailing dots separately to forbid ".", "..", and trailing-dot segments
// without introducing multi-quantifier regex backtracking.
return isNonEmptyString(segment) &&
!segment.endsWith('.') &&
/^[a-zA-Z0-9_.~-]+$/.test(segment);
}

/**
* Validates that a string is a valid tenant ID.
*
* @param tenantId - The string to validate.
* @returns Whether the string is a valid tenant ID.
*/
export function isTenantId(tenantId: any): tenantId is string {
return isSafeUrlSegment(tenantId);
}
88 changes: 79 additions & 9 deletions test/unit/auth/auth-api-request.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,20 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
return handler.init(mockApp);
}).not.to.throw(Error);
});

if (!handler.supportsTenantManagement) {
const invalidTenantIds = [
null, NaN, 0, 1, true, false, '', '.', '..', '...', 'tenant.', '..\n',
'tenant/1', 'tenant\\1', 'tenant?foo=bar', 'tenant#frag', 'tenant%2f1',
];
invalidTenantIds.forEach((invalidTenantId) => {
it('should throw given invalid tenantId: ' + JSON.stringify(invalidTenantId), () => {
expect(() => {
return new TenantAwareAuthRequestHandler(mockApp, invalidTenantId as any);
}).to.throw('The tenant ID must be a valid non-empty string.');
});
});
}
});

describe('Emulator Support', () => {
Expand Down Expand Up @@ -1559,6 +1573,36 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
}

if (handler.supportsTenantManagement) {
it('should fail client-side validation when user records have malformed tenant IDs', () => {
const invalidTenantIds = [
'', '.', '..', '...', 'tenant.', '..\n', 'tenant/1',
'tenant\\1', 'tenant?foo=bar', 'tenant#frag', 'tenant%2f1',
];
const testUsers: UserImportRecord[] = invalidTenantIds.map((invalidTenantId, i) => ({
uid: `user${i}`,
tenantId: invalidTenantId,
}));
const expectedResult = {
successCount: 0,
failureCount: invalidTenantIds.length,
errors: invalidTenantIds.map((_, index) => ({
index,
error: new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID),
})),
};
const stub = sinon.stub(HttpClient.prototype, 'send');
stubs.push(stub);

const requestHandler = handler.init(mockApp);
return requestHandler.uploadAccount(testUsers)
.then((result) => {
expectUserImportResult(result, expectedResult);
expect(stub).to.have.not.been.called;
});
});
}

it('should resolve successfully when 1000 UserImportRecords are provided', () => {
const expectedResult = utils.responseFrom({});
const stub = sinon.stub(HttpClient.prototype, 'send').resolves(expectedResult);
Expand Down Expand Up @@ -1748,6 +1792,10 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
],
},
},
{
uid: 'user23',
tenantId: '',
},
] as any;
const validOptions = {
hash: {
Expand Down Expand Up @@ -1842,6 +1890,7 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
`Unsupported second factor "${JSON.stringify(testUsers[22].multiFactor.enrolledFactors[0])}" provided.`,
),
},
{ index: 23, error: new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID) },
],
};
const stub = sinon.stub(HttpClient.prototype, 'send');
Expand Down Expand Up @@ -3375,7 +3424,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'oidc.', 'oidc..', 'oidc.provider.', 'oidc.\n',
'\uFEFFoidc.provider', 'oidc.provider/1', 'oidc.provider?foo=bar',
'oidc.provider#frag', 'oidc.provider%2f1', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -3552,7 +3603,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'oidc.', 'oidc..', 'oidc.provider.', 'oidc.\n',
'\uFEFFoidc.provider', 'oidc.provider/1', 'oidc.provider?foo=bar',
'oidc.provider#frag', 'oidc.provider%2f1', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -3842,7 +3895,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'oidc.', 'oidc..', 'oidc.provider.', 'oidc.\n',
'\uFEFFoidc.provider', 'oidc.provider/1', 'oidc.provider?foo=bar',
'oidc.provider#frag', 'oidc.provider%2f1', 'saml.provider', ['oidc.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -3939,7 +3994,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'saml.', 'saml..', 'saml.provider.', 'saml.\n',
'\uFEFFsaml.provider', 'saml.provider/1', 'saml.provider?foo=bar',
'saml.provider#frag', 'saml.provider%2f1', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -4110,7 +4167,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'saml.', 'saml..', 'saml.provider.', 'saml.\n',
'\uFEFFsaml.provider', 'saml.provider/1', 'saml.provider?foo=bar',
'saml.provider#frag', 'saml.provider%2f1', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -4386,7 +4445,9 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});

const invalidProviderIds = [
null, NaN, 0, 1, true, false, '', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
null, NaN, 0, 1, true, false, '', 'saml.', 'saml..', 'saml.provider.', 'saml.\n',
'\uFEFFsaml.provider', 'saml.provider/1', 'saml.provider?foo=bar',
'saml.provider#frag', 'saml.provider%2f1', 'oidc.provider', ['saml.provider'], [], {}, { a: 1 }, _.noop];
invalidProviderIds.forEach((invalidProviderId) => {
it('should be rejected given an invalid provider ID:' + JSON.stringify(invalidProviderId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_PROVIDER_ID);
Expand Down Expand Up @@ -4607,7 +4668,10 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
});

const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop];
const invalidTenantIds = [
null, NaN, 0, 1, true, false, '', '.', '..', '...', 'tenant.', '..\n',
'tenant/1', 'tenant\\1', 'tenant?foo=bar', 'tenant#frag', 'tenant%2f1',
['tenant-id'], [], {}, { a: 1 }, _.noop];
invalidTenantIds.forEach((invalidTenantId) => {
it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
Expand Down Expand Up @@ -4777,7 +4841,10 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
});

const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop];
const invalidTenantIds = [
null, NaN, 0, 1, true, false, '', '.', '..', '...', 'tenant.', '..\n',
'tenant/1', 'tenant\\1', 'tenant?foo=bar', 'tenant#frag', 'tenant%2f1',
['tenant-id'], [], {}, { a: 1 }, _.noop];
invalidTenantIds.forEach((invalidTenantId) => {
it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
Expand Down Expand Up @@ -5031,7 +5098,10 @@ AUTH_REQUEST_HANDLER_TESTS.forEach((handler) => {
});
});

const invalidTenantIds = [null, NaN, 0, 1, true, false, '', ['tenant-id'], [], {}, { a: 1 }, _.noop];
const invalidTenantIds = [
null, NaN, 0, 1, true, false, '', '.', '..', '...', 'tenant.', '..\n',
'tenant/1', 'tenant\\1', 'tenant?foo=bar', 'tenant#frag', 'tenant%2f1',
['tenant-id'], [], {}, { a: 1 }, _.noop];
invalidTenantIds.forEach((invalidTenantId) => {
it('should be rejected given an invalid tenant ID:' + JSON.stringify(invalidTenantId), () => {
const expectedError = new FirebaseAuthError(authClientErrorCode.INVALID_TENANT_ID);
Expand Down
Loading
Loading