Merge pull request #28000 from backstage/rugvip/stack

More safeguards against stack traces in error responses
This commit is contained in:
Patrik Oldsberg
2024-12-05 13:18:03 +01:00
committed by GitHub
11 changed files with 73 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
The user and plugin token verification in the default `AuthService` implementation will no longer forward verification errors to the caller, and instead log them as warnings.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
The default `authServiceFactory` now correctly depends on the plugin scoped `Logger` services rather than the root scoped one.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/errors': patch
---
Trim `error.cause.stack` in addition to `error.stack` when trimming stack traces from serialized errors.
@@ -109,8 +109,9 @@ describe('authServiceFactory', () => {
});
it('should authenticate issued tokens with new auth', async () => {
const logger = mockServices.logger.mock();
const tester = ServiceFactoryTester.from(authServiceFactory, {
dependencies: mockDeps,
dependencies: [...mockDeps, logger.factory],
});
const searchAuth = await tester.getSubject('search');
@@ -134,8 +135,13 @@ describe('authServiceFactory', () => {
targetPluginId: 'catalog',
});
expect(logger.warn).not.toHaveBeenCalled();
await expect(searchAuth.authenticate(searchToken)).rejects.toThrow(
'Invalid plugin token',
'Failed plugin token verification',
);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to verify incoming plugin token',
expect.any(Error),
);
await expect(catalogAuth.authenticate(searchToken)).resolves.toEqual(
expect.objectContaining({
@@ -59,7 +59,7 @@ export const authServiceFactory = createServiceFactory({
service: coreServices.auth,
deps: {
config: coreServices.rootConfig,
logger: coreServices.rootLogger,
logger: coreServices.logger,
discovery: coreServices.discovery,
plugin: coreServices.pluginMetadata,
database: coreServices.database,
@@ -89,6 +89,7 @@ export const authServiceFactory = createServiceFactory({
const userTokens = UserTokenHandler.create({
discovery,
logger,
});
const pluginTokens = pluginTokenHandlerDecorator(
@@ -120,7 +120,8 @@ export class DefaultPluginTokenHandler implements PluginTokenHandler {
requiredClaims: ['iat', 'exp', 'sub', 'aud'],
},
).catch(e => {
throw new AuthenticationError('Invalid plugin token', e);
this.logger.warn('Failed to verify incoming plugin token', e);
throw new AuthenticationError('Failed plugin token verification');
});
return { subject: `plugin:${payload.sub}`, limitedUserToken: payload.obo };
@@ -64,13 +64,17 @@ async function createToken(options: {
describe('UserTokenHandler', () => {
let userTokenHandler: UserTokenHandler;
const logger = mockServices.logger.mock();
registerMswTestHooks(server);
beforeEach(() => {
jest.useRealTimers();
jest.resetAllMocks();
userTokenHandler = UserTokenHandler.create({
discovery: mockServices.discovery(),
logger,
});
server.use(
@@ -120,7 +124,7 @@ describe('UserTokenHandler', () => {
signature: 'sig',
}),
),
).rejects.toThrow('signature verification failed');
).rejects.toThrow('Failed user token verification');
await expect(
userTokenHandler.verifyToken(
@@ -130,7 +134,7 @@ describe('UserTokenHandler', () => {
signature: 'sig',
}),
),
).rejects.toThrow('signature verification failed');
).rejects.toThrow('Failed user token verification');
});
it('should fail to verify tokens that have a bad alg', async () => {
@@ -156,8 +160,13 @@ describe('UserTokenHandler', () => {
});
const token = `${header}.${payload}.`;
expect(logger.warn).not.toHaveBeenCalled();
await expect(userTokenHandler.verifyToken(token)).rejects.toThrow(
/Unsupported "alg" value/,
'Failed user token verification',
);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to verify incoming user token',
expect.any(Error),
);
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DiscoveryService } from '@backstage/backend-plugin-api';
import { DiscoveryService, LoggerService } from '@backstage/backend-plugin-api';
import { AuthenticationError } from '@backstage/errors';
import { tokenTypes } from '@backstage/plugin-auth-node';
import {
@@ -33,15 +33,21 @@ import { JwksClient } from '../JwksClient';
* @internal
*/
export class UserTokenHandler {
static create(options: { discovery: DiscoveryService }): UserTokenHandler {
static create(options: {
discovery: DiscoveryService;
logger: LoggerService;
}): UserTokenHandler {
const jwksClient = new JwksClient(async () => {
const url = await options.discovery.getBaseUrl('auth');
return new URL(`${url}/.well-known/jwks.json`);
});
return new UserTokenHandler(jwksClient);
return new UserTokenHandler(jwksClient, options.logger);
}
constructor(private readonly jwksClient: JwksClient) {}
constructor(
private readonly jwksClient: JwksClient,
private readonly logger: LoggerService,
) {}
async verifyToken(token: string) {
const verifyOpts = this.#getTokenVerificationOptions(token);
@@ -57,7 +63,8 @@ export class UserTokenHandler {
this.jwksClient.getKey,
verifyOpts,
).catch(e => {
throw new AuthenticationError('Invalid token', e);
this.logger.warn('Failed to verify incoming user token', e);
throw new AuthenticationError('Failed user token verification');
});
const userEntityRef = payload.sub;
@@ -63,6 +63,20 @@ describe('serialization', () => {
expect(withoutStack2.stack).not.toBeDefined();
});
it('serializes stack traces only when allowed with error cause', () => {
const before = new CustomError('m');
before.cause = new Error('cause');
const withStack: any = serializeError(before, { includeStack: true });
const withoutStack1: any = serializeError(before, { includeStack: false });
const withoutStack2: any = serializeError(before);
expect(withStack.stack).toEqual(before.stack);
expect(withStack.cause.stack).toEqual(withStack.cause.stack);
expect(withoutStack1.stack).not.toBeDefined();
expect(withoutStack2.stack).not.toBeDefined();
expect(withoutStack1.cause.stack).not.toBeDefined();
expect(withoutStack2.cause.stack).not.toBeDefined();
});
it('stringifies all supported forms', () => {
expect(stringifyError({})).toEqual("unknown error '[object Object]'");
expect(
@@ -60,6 +60,14 @@ export function serializeError(
if (!options?.includeStack) {
delete result.stack;
if (
result.cause &&
typeof result.cause === 'object' &&
'stack' in result.cause
) {
delete result.cause.stack;
}
}
return result;
@@ -442,7 +442,6 @@ describe('Catalog Backend Integration', () => {
cause: {
name: 'Error',
message: 'NOPE',
stack: expect.stringMatching(/^Error: NOPE/),
},
},
},