Skip to content

Commit e7d75ec

Browse files
authored
feat: extend CSRF token protection to all api routes (#9422)
* feat(security): extend CSRF token protection to all api routes * update lock files * revert axios usage * fix unit tests * fix more tests * fix nits * fix failing test * rework axios mock * cleanup
1 parent a27c3e5 commit e7d75ec

File tree

30 files changed

+116
-76
lines changed

30 files changed

+116
-76
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
const axios = {
5+
get: jest.fn().mockReturnValue(Promise.resolve(new Proxy({}, {
6+
get() { return {} }
7+
})))
8+
}
9+
10+
module.exports = axios;
11+
module.exports.createAxios = jest.fn().mockReturnValue(axios)
12+
module.exports.axios = axios

Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import formatMessage from 'format-message';
88
import { BotTemplate } from '@bfc/shared';
99
import { navigate, RouteComponentProps } from '@reach/router';
1010
import querystring from 'query-string';
11-
import axios from 'axios';
11+
import { axios } from '@bfc/shared/lib/axios';
1212
import { useRecoilValue } from 'recoil';
1313

1414
import { getAliasFromPayload, isElectron } from '../../utils/electronUtil';

Composer/packages/client/src/components/ImportModal/ImportModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { navigate, RouteComponentProps } from '@reach/router';
88
import { Dialog, DialogType } from '@fluentui/react/lib/Dialog';
99
import { ExternalContentProviderType } from '@botframework-composer/types';
1010
import { useRecoilValue } from 'recoil';
11-
import axios from 'axios';
11+
import { axios } from '@bfc/shared/lib/axios';
1212

1313
import { dispatcherState } from '../../recoilModel';
1414
import { createNotification } from '../../recoilModel/dispatchers/notification';

Composer/packages/client/src/components/WebChat/WebChatPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ConversationNetworkTrafficItem,
99
ConversationNetworkErrorItem,
1010
} from '@botframework-composer/types';
11-
import { AxiosResponse } from 'axios';
11+
import type { AxiosResponse } from 'axios';
1212
import formatMessage from 'format-message';
1313
import { v4 as uuid } from 'uuid';
1414
import throttle from 'lodash/throttle';

Composer/packages/client/src/components/WebChat/utils/conversationService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Licensed under the MIT License.
33

44
import { v4 as uuidv4 } from 'uuid';
5-
import axios, { AxiosInstance, AxiosResponse } from 'axios';
5+
import type { AxiosInstance, AxiosResponse } from 'axios';
6+
import { axios, createAxios } from '@bfc/shared/lib/axios';
67
import { createStore as createWebChatStore } from 'botframework-webchat-core';
78
import { createDirectLine } from 'botframework-webchat';
89
import moment from 'moment';
@@ -21,7 +22,7 @@ export class ConversationService {
2122

2223
constructor(directlineHostUrl: string) {
2324
this.directlineHostUrl = directlineHostUrl.endsWith('/') ? directlineHostUrl.slice(0, -1) : directlineHostUrl;
24-
this.composerApiClient = axios.create({
25+
this.composerApiClient = createAxios({
2526
baseURL: directlineHostUrl,
2627
});
2728
this.setUpConversationServer();

Composer/packages/client/src/pages/publish/pullDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { PublishTarget } from '@botframework-composer/types';
77
import formatMessage from 'format-message';
88
import React, { useCallback, useEffect, useState } from 'react';
99
import { useRecoilValue } from 'recoil';
10-
import axios from 'axios';
10+
import { axios } from '@bfc/shared/lib/axios';
1111

1212
import { createNotification } from '../../recoilModel/dispatchers/notification';
1313
import { ImportSuccessNotificationWrapper } from '../../components/ImportModal/ImportSuccessNotification';

Composer/packages/client/src/types/window.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ declare global {
4040
/**
4141
* Token generated by the server, and sent with certain auth-related requests to the server to be verified and prevent CSRF attacks.
4242
*/
43-
__csrf__?: string;
43+
__csrf__: string;
4444
}
4545
}

Composer/packages/client/src/utils/authClient.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,20 @@ import { isElectron } from './electronUtil';
2222
import storage from './storage';
2323

2424
let idToken = getTokenFromCache('idToken');
25+
// eslint-disable-next-line no-underscore-dangle
26+
const fetchHeaders = { 'X-CSRF-Token': window.__csrf__ };
2527

2628
async function getAccessToken(options: AuthParameters): Promise<string> {
2729
const { targetResource = '', scopes = [] } = options;
2830
try {
2931
if (isElectron()) {
30-
const { __csrf__ = '' } = window;
31-
3232
let url = '/api/auth/getAccessToken?';
3333
const params = new URLSearchParams();
3434
if (targetResource) {
3535
params.append('targetResource', targetResource);
3636
}
3737
url += params.toString();
38-
const result = await fetch(url, { method: 'GET', headers: { 'X-CSRF-Token': __csrf__ } });
38+
const result = await fetch(url, { method: 'GET', headers: fetchHeaders });
3939
const { accessToken = '' } = await result.json();
4040
return accessToken;
4141
} else if (authConfig.clientId && authConfig.redirectUrl && authConfig.tenantId) {
@@ -98,7 +98,7 @@ async function logOut() {
9898
cleanTokenFromCache('graphToken');
9999
if (isElectron()) {
100100
const url = '/api/auth/logOut';
101-
const result = await fetch(url, { method: 'GET' });
101+
const result = await fetch(url, { method: 'GET', headers: fetchHeaders });
102102
return result.ok;
103103
} else if (authConfig.clientId) {
104104
// clean token cache in storage
@@ -118,12 +118,8 @@ async function logOut() {
118118
async function getARMTokenForTenant(tenantId: string): Promise<string> {
119119
const options = {
120120
method: 'GET',
121-
headers: {},
121+
headers: fetchHeaders,
122122
};
123-
if (isElectron()) {
124-
const { __csrf__ = '' } = window;
125-
options.headers['X-CSRF-Token'] = __csrf__;
126-
}
127123
// do we have a valid token in the cache for this tenant?
128124
if (getTokenFromCache(`token-${tenantId}`)) {
129125
return getTokenFromCache(`token-${tenantId}`);
@@ -150,12 +146,8 @@ async function getARMTokenForTenant(tenantId: string): Promise<string> {
150146
async function getTenants(): Promise<AzureTenant[]> {
151147
const options = {
152148
method: 'GET',
153-
headers: {},
149+
headers: fetchHeaders,
154150
};
155-
if (isElectron()) {
156-
const { __csrf__ = '' } = window;
157-
options.headers['X-CSRF-Token'] = __csrf__;
158-
}
159151

160152
const result = await fetch('/api/auth/getTenants', options);
161153
const { tenants = [] } = await result.json();
@@ -164,8 +156,7 @@ async function getTenants(): Promise<AzureTenant[]> {
164156

165157
async function getAccount() {
166158
if (isElectron()) {
167-
const { __csrf__ = '' } = window;
168-
const result = await fetch('/api/auth/getAccount', { method: 'GET', headers: { 'X-CSRF-Token': __csrf__ } });
159+
const result = await fetch('/api/auth/getAccount', { method: 'GET', headers: fetchHeaders });
169160
const { account = {} } = await result.json();
170161
return account;
171162
}

Composer/packages/client/src/utils/httpUtil.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
import axios from 'axios';
4+
import { createAxios } from '@bfc/shared/lib/axios';
55

66
import { BASEURL } from '../constants';
77

8-
const httpClient = axios.create({
8+
const httpClient = createAxios({
99
baseURL: BASEURL,
1010
});
1111

Composer/packages/extension-client/src/common/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ export function render(component: React.ReactElement) {
88
window[ComposerGlobalName].render(component);
99
}
1010

11+
const fetchHeaders = { 'X-CSRF-Token': window['__csrf__'] }
12+
1113
/** Allows extension client bundles to make AJAX calls from the server -- avoiding the issue of CORS */
1214
function fetchProxy(url: string, options: RequestInit) {
1315
return fetch(`/api/extensions/proxy/${encodeURIComponent(url)}`, {
1416
method: 'POST',
1517
body: JSON.stringify(options),
1618
headers: {
19+
...fetchHeaders,
1720
'Content-Type': 'application/json',
1821
},
1922
});

Composer/packages/lib/shared/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
"watch": "yarn build:ts --watch"
2121
},
2222
"peerDependencies": {
23+
"axios": "^0.21.1",
2324
"react": "16.13.1",
24-
"react-dom": "16.13.1"
25+
"react-dom": "16.13.1",
26+
"tslib": "2.4.0"
2527
},
2628
"devDependencies": {
2729
"@botframework-composer/test-utils": "*",
@@ -42,7 +44,6 @@
4244
"json-schema": "0.4.0",
4345
"multimatch": "^5.0.0",
4446
"nanoid": "^3.1.3",
45-
"nanoid-dictionary": "^3.0.0",
46-
"tslib": "2.4.0"
47+
"nanoid-dictionary": "^3.0.0"
4748
}
4849
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import Axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
5+
6+
export * from 'axios';
7+
8+
const csrfInterceptor = (config: AxiosRequestConfig) => {
9+
if (config.baseURL?.startsWith('/api') || config.url?.startsWith('/api')) {
10+
// eslint-disable-next-line no-underscore-dangle
11+
config.headers['X-CSRF-Token'] = ((window as unknown) as { __csrf__: string }).__csrf__;
12+
}
13+
return config;
14+
};
15+
16+
export const addCSRFInterceptor = (instance: AxiosInstance) => instance?.interceptors.request.use(csrfInterceptor);
17+
18+
export const createAxios: typeof Axios.create = (...args) => {
19+
const axiosInstance = Axios.create(...args);
20+
addCSRFInterceptor(axiosInstance);
21+
return axiosInstance;
22+
};
23+
24+
export const axios = createAxios();

Composer/packages/server/src/middleware/csrfProtection.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { authService } from '../services/auth/auth';
99
* Middleware that verifies if the server-generated CSRF token is sent with the incoming request via header.
1010
*/
1111
export const csrfProtection = (req: Request, res: Response, next?: NextFunction) => {
12-
// the CSRF token will only be generated in the production environment
1312
if (authService.csrfToken) {
1413
const csrfToken = req.get('X-CSRF-Token');
1514
if (!csrfToken) {

Composer/packages/server/src/router/api.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ import { UtilitiesController } from './../controllers/utilities';
2525

2626
const router: Router = express.Router({});
2727

28+
// Routes bellow are NOT CSRF protected
29+
// Place only routes loaded by browser script tags and navigation
30+
router.get('/extensions/settings/schema.json', ExtensionsController.getSettingsSchema);
31+
router.get('/extensions/:id/:bundleId', ExtensionsController.getBundleForView);
32+
33+
router.use(csrfProtection);
34+
35+
// Routes bellow are CSRF protected
2836
router.post('/projects', ProjectController.createProject);
2937
router.post('/projects/migrate', ProjectController.migrateProject);
3038
router.get('/projects', ProjectController.getAllProjects);
@@ -111,19 +119,17 @@ router.post('/extensions', ExtensionsController.addExtension);
111119
router.delete('/extensions', ExtensionsController.removeExtension);
112120
router.patch('/extensions/toggle', ExtensionsController.toggleExtension);
113121
router.get('/extensions/search', ExtensionsController.searchExtensions);
114-
router.get('/extensions/settings/schema.json', ExtensionsController.getSettingsSchema);
115122
router.get('/extensions/settings', ExtensionsController.getSettings);
116123
router.patch('/extensions/settings', ExtensionsController.updateSettings);
117-
router.get('/extensions/:id/:bundleId', ExtensionsController.getBundleForView);
118124
// proxy route for extensions (allows extension client code to make fetch calls using the Composer server as a proxy -- avoids browser blocking request due to CORS)
119125
router.post('/extensions/proxy/:url', ExtensionsController.performExtensionFetch);
120126

121127
// authentication from client
122-
router.get('/auth/getAccessToken', csrfProtection, AuthController.getAccessToken);
128+
router.get('/auth/getAccessToken', AuthController.getAccessToken);
123129
router.get('/auth/logOut', AuthController.logOut);
124-
router.get('/auth/getTenants', csrfProtection, AuthController.getTenants);
125-
router.get('/auth/getAccount', csrfProtection, AuthController.getAccount);
126-
router.get('/auth/getARMTokenForTenant', csrfProtection, AuthController.getARMTokenForTenant);
130+
router.get('/auth/getTenants', AuthController.getTenants);
131+
router.get('/auth/getAccount', AuthController.getAccount);
132+
router.get('/auth/getARMTokenForTenant', AuthController.getARMTokenForTenant);
127133

128134
// FeatureFlags
129135
router.get('/featureFlags', FeatureFlagController.getFeatureFlags);

Composer/packages/server/src/services/__tests__/auth.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ describe('auth service', () => {
2121
expect((authService as any)._csrfToken).toBeTruthy();
2222
});
2323

24-
it('should not generate a CSRF token in the development environment', () => {
24+
it('should use template as a CSRF token in the development environment', () => {
2525
Object.assign(process.env, { ...process.env, NODE_ENV: 'development' });
2626
// eslint-disable-next-line @typescript-eslint/no-var-requires
2727
const { authService } = require('../auth/auth');
2828

2929
// eslint-disable-next-line no-underscore-dangle
30-
expect((authService as any)._csrfToken).not.toBeTruthy();
30+
expect((authService as any)._csrfToken).toEqual('<?= __csrf__ ?>');
3131
});
3232

3333
it('should get an access token', async () => {

Composer/packages/server/src/services/auth/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ class AuthService {
3232
log('Production environment detected. Generating CSRF token.');
3333
this._csrfToken = uuid();
3434
} else {
35-
this._csrfToken = '';
35+
// use a placeholder as a token in development environment
36+
this._csrfToken = '<?= __csrf__ ?>';
3637
}
3738
}
3839

Composer/packages/types/src/shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT License.
33
/* eslint-disable @typescript-eslint/no-explicit-any */
44

5-
import { AxiosInstance } from 'axios';
5+
import type { AxiosInstance } from 'axios';
66

77
import { IDiagnostic } from './diagnostic';
88
import type {

Composer/yarn-berry.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4253,10 +4253,11 @@ __metadata:
42534253
react: 16.13.1
42544254
react-dom: 16.13.1
42554255
rimraf: 3.0.2
4256-
tslib: 2.4.0
42574256
peerDependencies:
4257+
axios: ^0.21.1
42584258
react: 16.13.1
42594259
react-dom: 16.13.1
4260+
tslib: 2.4.0
42604261
languageName: unknown
42614262
linkType: soft
42624263

extensions/azurePublish/src/components/api.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33
/* eslint-disable no-underscore-dangle */
4-
import axios from 'axios';
4+
import { axios } from '@bfc/shared/src/axios';
55
import formatMessage from 'format-message';
66
import { SubscriptionClient } from '@azure/arm-subscriptions';
77
import { Subscription } from '@azure/arm-subscriptions/esm/models';

extensions/azurePublish/src/node/luisAndQnA.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import * as path from 'path';
55

66
import * as fs from 'fs-extra';
7-
import { isUsingAdaptiveRuntime } from '@bfc/shared';
87
import { ILuisConfig, FileInfo, IBotProject, RuntimeTemplate, DialogSetting } from '@botframework-composer/types';
98
import axios, { AxiosRequestConfig } from 'axios';
109

extensions/azurePublish/yarn-berry.lock

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,7 +1908,7 @@ __metadata:
19081908

19091909
"@bfc/extension-client@file:../../Composer/packages/extension-client::locator=azurePublish%40workspace%3A.":
19101910
version: 1.0.0
1911-
resolution: "@bfc/extension-client@file:../../Composer/packages/extension-client#../../Composer/packages/extension-client::hash=1ccdd5&locator=azurePublish%40workspace%3A."
1911+
resolution: "@bfc/extension-client@file:../../Composer/packages/extension-client#../../Composer/packages/extension-client::hash=8dd52b&locator=azurePublish%40workspace%3A."
19121912
dependencies:
19131913
debug: ^4.1.1
19141914
lodash: ^4.17.19
@@ -1917,7 +1917,7 @@ __metadata:
19171917
"@botframework-composer/types": "*"
19181918
react: 16.13.1
19191919
react-dom: 16.13.1
1920-
checksum: e871e220343b04a641b46e94b9419281156c2f2920558dc345232eae130c0757bc386f4a567db50b9e77c4b1dc8ef393572277952859ca599319a697c77abff4
1920+
checksum: e915e895f8bddee9e70e18f12ba5ab4668bc6883ab3c9dd00833f3d7fb9be0e8cb0dbc35f686f4baf5baf31390f8e6bd037274219c68cea718b0d3f72d9ecc8a
19211921
languageName: node
19221922
linkType: hard
19231923

@@ -1938,7 +1938,7 @@ __metadata:
19381938

19391939
"@bfc/shared@file:../../Composer/packages/lib/shared::locator=azurePublish%40workspace%3A.":
19401940
version: 0.0.0
1941-
resolution: "@bfc/shared@file:../../Composer/packages/lib/shared#../../Composer/packages/lib/shared::hash=b1189d&locator=azurePublish%40workspace%3A."
1941+
resolution: "@bfc/shared@file:../../Composer/packages/lib/shared#../../Composer/packages/lib/shared::hash=8bb646&locator=azurePublish%40workspace%3A."
19421942
dependencies:
19431943
"@botframework-composer/types": "*"
19441944
format-message: 6.2.4
@@ -1947,11 +1947,12 @@ __metadata:
19471947
multimatch: ^5.0.0
19481948
nanoid: ^3.1.3
19491949
nanoid-dictionary: ^3.0.0
1950-
tslib: 2.4.0
19511950
peerDependencies:
1951+
axios: ^0.21.1
19521952
react: 16.13.1
19531953
react-dom: 16.13.1
1954-
checksum: d6d584b1231582c660585be243a577ecc112e3834ce711241ec981d354bbd3efc4084d1a556c3bf6aa155f2506101e1f33f7fb3eddb154657eac688ba6c167f8
1954+
tslib: 2.4.0
1955+
checksum: ed6af3f1b57b302c951d934f2686f139541e99eb23765906e96f3d4b024b059f0e17f1af869939a6bf8170b366b3d0034a8fa4855395ad545f430a76e377b545
19551956
languageName: node
19561957
linkType: hard
19571958

@@ -1996,7 +1997,7 @@ __metadata:
19961997

19971998
"@botframework-composer/types@file:../../Composer/packages/types::locator=azurePublish%40workspace%3A.":
19981999
version: 0.0.2
1999-
resolution: "@botframework-composer/types@file:../../Composer/packages/types#../../Composer/packages/types::hash=33ce41&locator=azurePublish%40workspace%3A."
2000+
resolution: "@botframework-composer/types@file:../../Composer/packages/types#../../Composer/packages/types::hash=ff5977&locator=azurePublish%40workspace%3A."
20002001
dependencies:
20012002
"@types/express": 4.16.1
20022003
"@types/passport": ^1.0.4
@@ -2005,7 +2006,7 @@ __metadata:
20052006
express-serve-static-core: 0.1.1
20062007
json-schema: 0.4.0
20072008
tslib: 2.4.0
2008-
checksum: ca240110a807681973dcb661819b8979d4b05ad08763a171c17a10ba25aad32e3f0ec5125cb6b2462d0a6e1afa5b13b2c4dc336c1529a15ea4ac1c47654e0e31
2009+
checksum: 377541c2d09305b36e9e12a1b200725bf7c37580a959f2d24007f51052b5f9536b7e0dea48f1f7b574fcca0b8858c97658f413c2df50238f9e70cdcbeaf678e5
20092010
languageName: node
20102011
linkType: hard
20112012

0 commit comments

Comments
 (0)