Skip to content

Commit e47544f

Browse files
Merge pull request #18164 from mozilla/FXA-10626
feat(shared): SanitizeExceptions decorator Because: * In instances where the client can call methods "directly" (such as via NextJS Server Actions), we need to ensure that the only data that gets passed through to the user is what is intended. Error propagation is a common way for unexpected data to leak to the user. This commit: * Adds a SanitizeExceptions decorator to libs/shared/error that wraps a class, and captures any errors thrown by its methods. Unless specified, errors are replaced with a generic error message. Original error data is sent to Sentry, along with a data tag to indicate whether it was sanitized or passed through. Closes # FXA-10626
2 parents bb3b09d + db54463 commit e47544f

7 files changed

Lines changed: 244 additions & 10 deletions

File tree

libs/payments/cart/src/lib/cart.service.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ import { NeedsInputType } from './cart.types';
9696
import { redirect } from 'next/navigation';
9797

9898
jest.mock('next/navigation');
99+
jest.mock('@fxa/shared/error', () => ({
100+
...jest.requireActual('@fxa/shared/error'),
101+
SanitizeExceptions: jest.fn(({ allowlist = [] } = {}) => {
102+
return function (
103+
target: any,
104+
propertyKey: string,
105+
descriptor: PropertyDescriptor
106+
) {
107+
return descriptor;
108+
};
109+
}),
110+
}));
99111

100112
describe('CartService', () => {
101113
let accountManager: AccountManager;

libs/payments/cart/src/lib/cart.service.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
SubscriptionManager,
1414
InvoicePreview,
1515
PaymentMethodManager,
16+
CouponErrorExpired,
17+
CouponErrorGeneric,
18+
CouponErrorLimitReached,
1619
} from '@fxa/payments/customer';
1720
import { EligibilityService } from '@fxa/payments/eligibility';
1821
import {
@@ -26,10 +29,9 @@ import { CartErrorReasonId, CartState } from '@fxa/shared/db/mysql/account';
2629
import { GeoDBManager } from '@fxa/shared/geodb';
2730

2831
import { CartManager } from './cart.manager';
29-
import {
32+
import type {
3033
CheckoutCustomerData,
3134
GetNeedsInputResponse,
32-
NeedsInputType,
3335
NoInputNeededResponse,
3436
PaymentInfo,
3537
ResultCart,
@@ -38,6 +40,7 @@ import {
3840
UpdateCart,
3941
WithContextCart,
4042
} from './cart.types';
43+
import { NeedsInputType } from './cart.types';
4144
import { handleEligibilityStatusMap } from './cart.utils';
4245
import { CheckoutService } from './checkout.service';
4346
import {
@@ -53,6 +56,7 @@ import {
5356
import { AccountManager } from '@fxa/shared/account/account';
5457
import assert from 'assert';
5558
import { CheckoutFailedError } from './checkout.error';
59+
import { SanitizeExceptions } from '@fxa/shared/error';
5660

5761
// TODO - Add flow to handle situations where currency is not found
5862
const DEFAULT_CURRENCY = 'USD';
@@ -114,6 +118,7 @@ export class CartService {
114118
* Initialize a brand new cart
115119
* **Note**: This method is currently a placeholder. The arguments will likely change, and the internal implementation is far from complete.
116120
*/
121+
@SanitizeExceptions({ allowlist: [CartInvalidPromoCodeError] })
117122
async setupCart(args: {
118123
interval: SubplatInterval;
119124
offeringConfigId: string;
@@ -218,6 +223,7 @@ export class CartService {
218223
/**
219224
* Create a new cart with the contents of an existing cart, in the initial state.
220225
*/
226+
@SanitizeExceptions()
221227
async restartCart(cartId: string): Promise<ResultCart> {
222228
return this.wrapWithCartCatch(cartId, async () => {
223229
const oldCart = await this.cartManager.fetchCartById(cartId);
@@ -255,6 +261,7 @@ export class CartService {
255261
});
256262
}
257263

264+
@SanitizeExceptions()
258265
async checkoutCartWithStripe(
259266
cartId: string,
260267
version: number,
@@ -289,6 +296,7 @@ export class CartService {
289296
});
290297
}
291298

299+
@SanitizeExceptions()
292300
async checkoutCartWithPaypal(
293301
cartId: string,
294302
version: number,
@@ -323,6 +331,7 @@ export class CartService {
323331
});
324332
}
325333

334+
@SanitizeExceptions()
326335
async finalizeProcessingCart(cartId: string): Promise<void> {
327336
return this.wrapWithCartCatch(cartId, async () => {
328337
const cart = await this.cartManager.fetchCartById(cartId);
@@ -353,6 +362,7 @@ export class CartService {
353362
* Update a cart in the database by ID or with an existing cart reference
354363
* **Note**: This method is currently a placeholder. The arguments will likely change, and the internal implementation is far from complete.
355364
*/
365+
@SanitizeExceptions()
356366
async finalizeCartWithError(
357367
cartId: string,
358368
errorReasonId: CartErrorReasonId
@@ -372,6 +382,13 @@ export class CartService {
372382
/**
373383
* Update a cart in the database by ID or with an existing cart reference
374384
*/
385+
@SanitizeExceptions({
386+
allowlist: [
387+
CouponErrorExpired,
388+
CouponErrorGeneric,
389+
CouponErrorLimitReached,
390+
],
391+
})
375392
async updateCart(cartId: string, version: number, cartDetails: UpdateCart) {
376393
return this.wrapWithCartCatch(cartId, async () => {
377394
const oldCart = await this.cartManager.fetchCartById(cartId);
@@ -407,6 +424,7 @@ export class CartService {
407424
/**
408425
* Fetch a cart from the database by ID
409426
*/
427+
@SanitizeExceptions()
410428
async getCart(cartId: string): Promise<WithContextCart> {
411429
const cart = await this.cartManager.fetchCartById(cartId);
412430

@@ -478,6 +496,7 @@ export class CartService {
478496
/**
479497
* Fetch a success cart from the database by UID
480498
*/
499+
@SanitizeExceptions({ allowlist: [CartInvalidStateForActionError] })
481500
async getSuccessCart(cartId: string): Promise<SuccessCart> {
482501
const cart = await this.getCart(cartId);
483502

@@ -511,6 +530,7 @@ export class CartService {
511530
}
512531
}
513532

533+
@SanitizeExceptions()
514534
async getNeedsInput(cartId: string): Promise<GetNeedsInputResponse> {
515535
const cart = await this.cartManager.fetchCartById(cartId);
516536

@@ -554,6 +574,7 @@ export class CartService {
554574
}
555575
}
556576

577+
@SanitizeExceptions({ allowlist: [CheckoutFailedError] })
557578
async submitNeedsInput(cartId: string) {
558579
return this.wrapWithCartCatch(cartId, async () => {
559580
const cart = await this.cartManager.fetchCartById(cartId);

libs/payments/customer/project.json

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,6 @@
5050
"jestConfig": "libs/payments/customer/jest.config.ts",
5151
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
5252
}
53-
},
54-
"test-integration": {
55-
"executor": "@nx/jest:jest",
56-
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
57-
"options": {
58-
"jestConfig": "libs/payments/customer/jest.config.ts",
59-
"testPathPattern": ["\\.in\\.spec\\.ts$"]
60-
}
6153
}
6254
}
6355
}

libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessin
2626
import { SubmitNeedsInputActionArgs } from './validators/SubmitNeedsInputActionArgs';
2727
import { GetNeedsInputActionArgs } from './validators/GetNeedsInputActionArgs';
2828
import { ValidatePostalCodeArgs } from './validators/ValidatePostalCodeArgs';
29+
import { SanitizeExceptions } from '@fxa/shared/error';
2930

3031
/**
3132
* ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments.
@@ -102,6 +103,7 @@ export class NextJSActionsService {
102103
await this.cartService.finalizeProcessingCart(args.cartId);
103104
}
104105

106+
@SanitizeExceptions()
105107
async getPayPalCheckoutToken(args: GetPayPalCheckoutTokenArgs) {
106108
await new Validator().validateOrReject(args);
107109

@@ -132,6 +134,7 @@ export class NextJSActionsService {
132134
);
133135
}
134136

137+
@SanitizeExceptions()
135138
async fetchCMSData(args: FetchCMSDataArgs) {
136139
await new Validator().validateOrReject(args);
137140

@@ -143,6 +146,7 @@ export class NextJSActionsService {
143146
return offering;
144147
}
145148

149+
@SanitizeExceptions()
146150
async recordEmitterEvent(args: RecordEmitterEventArgs) {
147151
await new Validator().validateOrReject(args);
148152

@@ -183,10 +187,12 @@ export class NextJSActionsService {
183187
return await this.cartService.submitNeedsInput(args.cartId);
184188
}
185189

190+
@SanitizeExceptions()
186191
async getMetricsFlow() {
187192
return this.contentServerManager.getMetricsFlow();
188193
}
189194

195+
@SanitizeExceptions()
190196
async validatePostalCode(args: ValidatePostalCodeArgs) {
191197
await new Validator().validateOrReject(args);
192198

libs/shared/error/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
export { BaseError, BaseMultiError, TypeError } from './lib/error';
5+
export { SanitizeExceptions } from './lib/sanitizeExceptionsDecorator';
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Test, TestingModule } from '@nestjs/testing';
6+
import { SanitizeExceptions } from './sanitizeExceptionsDecorator';
7+
import { LOGGER_PROVIDER } from '@fxa/shared/log';
8+
9+
// Mock Sentry
10+
jest.mock('@sentry/nextjs', () => ({
11+
withScope: jest.fn((callback) => callback({ setExtra: jest.fn() })),
12+
captureException: jest.fn(),
13+
}));
14+
15+
class SensitiveError extends Error {
16+
public subtype: string;
17+
constructor(message: string) {
18+
super(message);
19+
this.subtype = 'SensitiveError';
20+
}
21+
}
22+
23+
class AcceptableError extends Error {
24+
public subtype: string;
25+
constructor(message: string) {
26+
super(message);
27+
this.subtype = 'AcceptableError';
28+
}
29+
}
30+
31+
class MockService {
32+
@SanitizeExceptions({ allowlist: [AcceptableError] })
33+
throwSensitiveError() {
34+
throw new SensitiveError('Sensitive error');
35+
}
36+
37+
@SanitizeExceptions({ allowlist: [AcceptableError] })
38+
async throwSensitiveErrorAsync() {
39+
throw new SensitiveError('Sensitive error');
40+
}
41+
42+
@SanitizeExceptions({ allowlist: [AcceptableError] })
43+
throwAcceptableError() {
44+
throw new AcceptableError('Acceptable error');
45+
}
46+
47+
@SanitizeExceptions({ allowlist: [AcceptableError] })
48+
async throwAcceptableErrorAsync() {
49+
throw new AcceptableError('Acceptable error');
50+
}
51+
}
52+
53+
describe('SanitizeExceptions Decorator', () => {
54+
let service: MockService;
55+
let mockLogger: { error: jest.Mock; info: jest.Mock };
56+
57+
beforeEach(async () => {
58+
mockLogger = {
59+
error: jest.fn(),
60+
info: jest.fn(),
61+
};
62+
63+
const module: TestingModule = await Test.createTestingModule({
64+
providers: [
65+
MockService,
66+
{
67+
provide: LOGGER_PROVIDER,
68+
useValue: mockLogger,
69+
},
70+
],
71+
}).compile();
72+
73+
service = module.get<MockService>(MockService);
74+
});
75+
76+
afterEach(() => {
77+
jest.clearAllMocks();
78+
});
79+
80+
describe('synchronous methods', () => {
81+
it('should pass acceptable errors through', () => {
82+
expect(() => service.throwAcceptableError()).toThrow(AcceptableError);
83+
expect(mockLogger.error).toHaveBeenCalledWith(
84+
'Error caught by SanitizeExceptions decorator',
85+
expect.any(AcceptableError)
86+
);
87+
});
88+
89+
it('should sanitize sensitive errors', () => {
90+
expect(() => service.throwSensitiveError()).toThrow(
91+
'Something went wrong'
92+
);
93+
expect(() => service.throwSensitiveError()).not.toThrow(SensitiveError);
94+
expect(mockLogger.error).toHaveBeenCalledWith(
95+
'Error caught by SanitizeExceptions decorator',
96+
expect.any(SensitiveError)
97+
);
98+
});
99+
});
100+
101+
describe('asynchronous methods', () => {
102+
it('should pass acceptable errors through', async () => {
103+
await expect(service.throwAcceptableErrorAsync()).rejects.toThrow(
104+
AcceptableError
105+
);
106+
expect(mockLogger.error).toHaveBeenCalledWith(
107+
'Error caught by SanitizeExceptions decorator',
108+
expect.any(AcceptableError)
109+
);
110+
});
111+
112+
it('should sanitize sensitive errors', async () => {
113+
await expect(service.throwSensitiveErrorAsync()).rejects.toThrow(
114+
'Something went wrong'
115+
);
116+
await expect(service.throwSensitiveErrorAsync()).rejects.not.toThrow(
117+
SensitiveError
118+
);
119+
expect(mockLogger.error).toHaveBeenCalledWith(
120+
'Error caught by SanitizeExceptions decorator',
121+
expect.any(SensitiveError)
122+
);
123+
});
124+
});
125+
});

0 commit comments

Comments
 (0)