Skip to content

Commit b2c7dac

Browse files
committed
fix: freeze lambda context object (silvermine#18)
1 parent 250c469 commit b2c7dac

File tree

6 files changed

+120
-10
lines changed

6 files changed

+120
-10
lines changed

src/Application.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Callback } from 'aws-lambda';
1+
import { Callback, Context } from 'aws-lambda';
22
import Router from './Router';
33
import { RequestEvent, HandlerContext } from './request-response-types';
4-
import { StringUnknownMap } from './utils/common-types';
4+
import { StringUnknownMap, Writable } from './utils/common-types';
55
import { Request, Response } from '.';
6+
import _ from 'underscore';
67

78
export default class Application extends Router {
89

@@ -85,8 +86,8 @@ export default class Application extends Router {
8586
* @param context The context provided to the Lambda handler
8687
* @param cb The callback provided to the Lambda handler
8788
*/
88-
public run(evt: RequestEvent, context: HandlerContext, cb: Callback): void {
89-
const req = new Request(this, evt, context),
89+
public run(evt: RequestEvent, context: Context, cb: Callback): void {
90+
const req = new Request(this, evt, this._createHandlerContext(context)),
9091
resp = new Response(this, req, cb);
9192

9293
this.handle(undefined, req, resp, (err: unknown): void => {
@@ -99,4 +100,29 @@ export default class Application extends Router {
99100
});
100101
}
101102

103+
private _createHandlerContext(context: Context): HandlerContext {
104+
// keys should exist on both `HandlerContext` and `Context`
105+
const keys: (keyof HandlerContext & keyof Context)[] = [
106+
'functionName', 'functionVersion', 'invokedFunctionArn', 'memoryLimitInMB',
107+
'awsRequestId', 'logGroupName', 'logStreamName', 'identity', 'clientContext',
108+
'getRemainingTimeInMillis',
109+
];
110+
111+
let handlerContext: Writable<HandlerContext>;
112+
113+
handlerContext = _.reduce(keys, (memo, key) => {
114+
let contextValue = context[key];
115+
116+
if (typeof contextValue === 'object' && contextValue) {
117+
// Freeze sub-objects
118+
memo[key] = Object.freeze(_.extend({}, contextValue));
119+
} else if (typeof contextValue !== 'undefined') {
120+
memo[key] = contextValue;
121+
}
122+
return memo;
123+
}, {} as Writable<HandlerContext>);
124+
125+
return Object.freeze(handlerContext);
126+
}
127+
102128
}

src/Request.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,6 @@ export default class Request {
246246

247247
this.eventSourceType = ('elb' in event.requestContext) ? Request.SOURCE_ALB : Request.SOURCE_APIGW;
248248

249-
// TODO: should something be done to limit what's exposed by these contexts? For
250-
// example, make properties read-only and don't expose the callback function, etc.
251249
this.context = context;
252250
this.requestContext = event.requestContext;
253251

src/request-response-types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,18 @@ export interface ResponseResult extends APIGatewayProxyResult {
3030
* The `context` object passed to a Lambda handler.
3131
*/
3232
// eslint-disable-next-line @typescript-eslint/no-empty-interface
33-
export interface HandlerContext extends Context {}
33+
export interface HandlerContext extends Readonly<Pick<Context,
34+
'functionName'
35+
| 'functionVersion'
36+
| 'invokedFunctionArn'
37+
| 'memoryLimitInMB'
38+
| 'awsRequestId'
39+
| 'logGroupName'
40+
| 'logStreamName'
41+
| 'identity'
42+
| 'clientContext'
43+
| 'getRemainingTimeInMillis'
44+
>> {}
3445

3546

3647
/* API GATEWAY TYPES (we export these with our own names to make it easier to modify them

src/utils/common-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export interface StringMap { [s: string]: string }
44
export interface StringUnknownMap { [s: string]: unknown }
55
export interface StringArrayOfStringsMap { [s: string]: string[] }
66
export interface KeyValueStringObject { [k: string]: (string | string[] | KeyValueStringObject) }
7+
// Removes `readonly` modifier and make all keys writable again
8+
export type Writable<T> = { -readonly [P in keyof T]-?: T[P] };
79

810
export function isStringMap(o: any): o is StringMap {
911
if (!_.isObject(o) || _.isArray(o)) { // because arrays are objects

tests/integration-tests.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,4 +547,49 @@ describe('integration tests', () => {
547547

548548
});
549549

550+
describe('request object', () => {
551+
552+
it('has an immutable context property', () => {
553+
let evt = makeRequestEvent('/test', apiGatewayRequest(), 'GET'),
554+
ctx = handlerContext(true),
555+
handler;
556+
557+
function isPropFrozen(obj: any, key: string): boolean {
558+
try {
559+
obj[key] = 'change';
560+
return false;
561+
} catch(e) {
562+
if (e instanceof Error) {
563+
return e.message.indexOf('Cannot assign to read only property') !== -1;
564+
}
565+
return false;
566+
}
567+
}
568+
569+
handler = spy((req: Request, resp: Response) => {
570+
expect(req.context).to.be.an('object');
571+
572+
expect(isPropFrozen(req.context, 'awsRequestId'));
573+
expect(isPropFrozen(req.context, 'clientContext'));
574+
575+
if (req.context.clientContext) {
576+
expect(isPropFrozen(req.context.clientContext, 'clientContext'));
577+
}
578+
579+
if (req.context.identity) {
580+
expect(isPropFrozen(req.context.identity, 'cognitoIdentityId'));
581+
}
582+
583+
resp.send('test');
584+
});
585+
app.get('*', handler);
586+
587+
app.run(evt, ctx, spy());
588+
589+
// Make sure the handler ran, otherwise the test is invalid.
590+
assert.calledOnce(handler);
591+
});
592+
593+
});
594+
550595
});

tests/samples.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import {
33
APIGatewayEventRequestContext,
44
ApplicationLoadBalancerEventRequestContext,
55
APIGatewayRequestEvent,
6-
HandlerContext,
76
ApplicationLoadBalancerRequestEvent } from '../src/request-response-types';
7+
import { Context } from 'aws-lambda';
88

9-
export const handlerContext = (): HandlerContext => {
10-
return {
9+
export const handlerContext = (fillAllFields: boolean = false): Context => {
10+
let ctx: Context;
11+
12+
ctx = {
1113
callbackWaitsForEmptyEventLoop: true,
1214
logGroupName: '/aws/lambda/echo-api-prd-echo',
1315
logStreamName: '2019/01/31/[$LATEST]bb001267fb004ffa8e1710bba30b4ae7',
@@ -21,6 +23,32 @@ export const handlerContext = (): HandlerContext => {
2123
fail: () => undefined,
2224
succeed: () => undefined,
2325
};
26+
27+
if (fillAllFields) {
28+
ctx.identity = {
29+
cognitoIdentityId: 'cognitoIdentityId',
30+
cognitoIdentityPoolId: 'cognitoIdentityPoolId',
31+
};
32+
33+
ctx.clientContext = {
34+
client: {
35+
installationId: 'installationId',
36+
appTitle: 'appTitle',
37+
appVersionName: 'appVersionName',
38+
appVersionCode: 'appVersionCode',
39+
appPackageName: 'appPackageName',
40+
},
41+
env: {
42+
platformVersion: 'platformVersion',
43+
platform: 'platform',
44+
make: 'make',
45+
model: 'model',
46+
locale: 'locale',
47+
},
48+
};
49+
}
50+
51+
return ctx;
2452
};
2553

2654
export const apiGatewayRequestContext = (): APIGatewayEventRequestContext => {

0 commit comments

Comments
 (0)