Skip to content

Commit 36c25ae

Browse files
authored
Update CSP handler to only query and modify frame ancestors instead of all CSP directives (#6398)
* only allow updating frame ancestors Signed-off-by: Tianle Huang <tianleh@amazon.com> * refactor Signed-off-by: Tianle Huang <tianleh@amazon.com> * add test Signed-off-by: Tianle Huang <tianleh@amazon.com> * fix link Signed-off-by: Tianle Huang <tianleh@amazon.com> * update key Signed-off-by: Tianle Huang <tianleh@amazon.com> * rename Signed-off-by: Tianle Huang <tianleh@amazon.com> * update unit tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * update tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * add change log Signed-off-by: Tianle Huang <tianleh@amazon.com> * undo yml Signed-off-by: Tianle Huang <tianleh@amazon.com> * update readme and variable Signed-off-by: Tianle Huang <tianleh@amazon.com> * fix link Signed-off-by: Tianle Huang <tianleh@amazon.com> * make code generic Signed-off-by: Tianle Huang <tianleh@amazon.com> * revert yml Signed-off-by: Tianle Huang <tianleh@amazon.com> * add more tests Signed-off-by: Tianle Huang <tianleh@amazon.com> * update key name Signed-off-by: Tianle Huang <tianleh@amazon.com> * reword change title Signed-off-by: Tianle Huang <tianleh@amazon.com> * fix typo Signed-off-by: Tianle Huang <tianleh@amazon.com> --------- Signed-off-by: Tianle Huang <tianleh@amazon.com>
1 parent e785fd3 commit 36c25ae

File tree

6 files changed

+272
-47
lines changed

6 files changed

+272
-47
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
8989
- [Multiple Datasource] Refactor data source selector component to include placeholder and add tests ([#6372](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6372))
9090
- Replace control characters before logging ([#6402](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6402))
9191
- [Dynamic Configurations] Improve dynamic configurations by adding cache and simplifying client fetch ([#6364](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6364))
92+
- [CSP Handler] Update CSP handler to only query and modify frame ancestors instead of all CSP directives ([#6398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6398))
9293
- [MD] Add OpenSearch cluster group label to top of single selectable dropdown ([#6400](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6400))
9394
- [Workspace] Support workspace in saved objects client in server side. ([#6365](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6365))
9495

src/plugins/csp_handler/README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A OpenSearch Dashboards plugin
44

5-
This plugin is to support updating Content Security Policy (CSP) rules dynamically without requiring a server restart. It registers a pre-response handler to `HttpServiceSetup` which can get CSP rules from a dependent plugin `applicationConfig` and then rewrite to CSP header. Users are able to call the API endpoint exposed by the `applicationConfig` plugin directly, e.g through CURL. Currently there is no new OSD page for ease of user interactions with the APIs. Updates to the CSP rules will take effect immediately. As a comparison, modifying CSP rules through the key `csp.rules` in OSD YAML file would require a server restart.
5+
This plugin is to support updating the `frame-ancestors` directive in Content Security Policy (CSP) rules dynamically without requiring a server restart. It registers a pre-response handler to `HttpServiceSetup` which can get the `frame-ancestors` directive from a dependent plugin `applicationConfig` and then rewrite to CSP header. It will not change other directives. Users are able to call the API endpoint exposed by the `applicationConfig` plugin directly, e.g through CURL. The configuration key is `csp.rules.frame-ancestors`. Currently there is no new OSD page for ease of user interactions with the APIs. Updates to the `frame-ancestors` directive will take effect immediately. As a comparison, modifying CSP rules through the key `csp.rules` in OSD YAML file would require a server restart.
66

77
By default, this plugin is disabled. Once enabled, the plugin will first use what users have configured through `applicationConfig`. If not configured, it will check whatever CSP rules aggregated by the values of `csp.rules` from OSD YAML file and default values. If the aggregated CSP rules don't contain the CSP directive `frame-ancestors` which specifies valid parents that may embed OSD page, then the plugin will append `frame-ancestors 'self'` to prevent Clickjacking.
88

@@ -23,23 +23,23 @@ Since it has a required dependency `applicationConfig`, make sure that the depen
2323
application_config.enabled: true
2424
```
2525

26-
For OSD users who want to make changes to allow a new site to embed OSD pages, they can update CSP rules through CURL. (See the README of `applicationConfig` for more details about the APIs.) **Please note that use backslash as string wrapper for single quotes inside the `data-raw` parameter. E.g use `'\''` to represent `'`**
26+
For OSD users who want to make changes to allow a new site to embed OSD pages, they can update the `frame-ancestors` directive through CURL. (See the README of `applicationConfig` for more details about the APIs.) **Please note that use backslash as string wrapper for single quotes inside the `data-raw` parameter. E.g use `'\''` to represent `'`**
2727

2828
```
29-
curl '{osd endpoint}/api/appconfig/csp.rules' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"script-src '\''unsafe-eval'\'' '\''self'\''; worker-src blob: '\''self'\''; style-src '\''unsafe-inline'\'' '\''self'\''; frame-ancestors '\''self'\'' {new site}"}'
29+
curl '{osd endpoint}/api/appconfig/csp.rules.frame-ancestors' -X POST -H 'Accept: application/json' -H 'Content-Type: application/json' -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty' --data-raw '{"newValue":"{new value}"}'
3030
3131
```
3232

33-
Below is the CURL command to delete CSP rules.
33+
Below is the CURL command to delete the `frame-ancestors` directive.
3434

3535
```
36-
curl '{osd endpoint}/api/appconfig/csp.rules' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty'
36+
curl '{osd endpoint}/api/appconfig/csp.rules.frame-ancestors' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty'
3737
```
3838

39-
Below is the CURL command to get the CSP rules.
39+
Below is the CURL command to get the `frame-ancestors` directive.
4040

4141
```
42-
curl '{osd endpoint}/api/appconfig/csp.rules'
42+
curl '{osd endpoint}/api/appconfig/csp.rules.frame-ancestors'
4343
4444
```
4545

src/plugins/csp_handler/server/csp_handlers.test.ts

Lines changed: 130 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createCspRulesPreResponseHandler } from './csp_handlers';
88
import { MockedLogger, loggerMock } from '@osd/logging/target/mocks';
99

1010
const ERROR_MESSAGE = 'Service unavailable';
11+
const CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY = 'csp.rules.frame-ancestors';
1112

1213
describe('CSP handlers', () => {
1314
let toolkit: ReturnType<typeof httpServerMock.createToolkit>;
@@ -18,13 +19,13 @@ describe('CSP handlers', () => {
1819
logger = loggerMock.create();
1920
});
2021

21-
it('adds the CSP headers provided by the client', async () => {
22+
it('adds the frame-ancestors provided by the client', async () => {
2223
const coreSetup = coreMock.createSetup();
23-
const cspRulesFromIndex = "frame-ancestors 'self'";
24+
const frameAncestorsFromIndex = "'self' localsystem";
2425
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
2526

2627
const configurationClient = {
27-
getEntityConfig: jest.fn().mockReturnValue(cspRulesFromIndex),
28+
getEntityConfig: jest.fn().mockReturnValue(frameAncestorsFromIndex),
2829
};
2930

3031
const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);
@@ -50,21 +51,26 @@ describe('CSP handlers', () => {
5051

5152
expect(toolkit.next).toHaveBeenCalledWith({
5253
headers: {
53-
'content-security-policy': cspRulesFromIndex,
54+
'content-security-policy':
55+
"script-src 'unsafe-eval' 'self'; frame-ancestors 'self' localsystem",
5456
},
5557
});
5658

5759
expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
60+
expect(configurationClient.getEntityConfig).toBeCalledWith(
61+
CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY,
62+
{ headers: { 'sec-fetch-dest': 'document' } }
63+
);
5864
expect(getConfigurationClient).toBeCalledWith(request);
5965
});
6066

61-
it('do not add CSP headers when the client returns empty and CSP from YML already has frame-ancestors', async () => {
67+
it('do not modify frame-ancestors when the client returns empty and CSP from YML already has frame-ancestors', async () => {
6268
const coreSetup = coreMock.createSetup();
63-
const emptyCspRules = '';
64-
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'";
69+
const emptyFrameAncestors = '';
70+
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors localsystem";
6571

6672
const configurationClient = {
67-
getEntityConfig: jest.fn().mockReturnValue(emptyCspRules),
73+
getEntityConfig: jest.fn().mockReturnValue(emptyFrameAncestors),
6874
};
6975

7076
const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);
@@ -87,19 +93,29 @@ describe('CSP handlers', () => {
8793
expect(result).toEqual('next');
8894

8995
expect(toolkit.next).toHaveBeenCalledTimes(1);
90-
expect(toolkit.next).toHaveBeenCalledWith({});
96+
expect(toolkit.next).toHaveBeenCalledWith({
97+
headers: {
98+
'content-security-policy': cspRulesFromYML,
99+
},
100+
});
91101

92102
expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
103+
104+
expect(configurationClient.getEntityConfig).toBeCalledWith(
105+
CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY,
106+
{ headers: { 'sec-fetch-dest': 'document' } }
107+
);
108+
93109
expect(getConfigurationClient).toBeCalledWith(request);
94110
});
95111

96-
it('add frame-ancestors CSP headers when the client returns empty and CSP from YML has no frame-ancestors', async () => {
112+
it('add frame-ancestors when the client returns empty and CSP from YML has no frame-ancestors', async () => {
97113
const coreSetup = coreMock.createSetup();
98-
const emptyCspRules = '';
114+
const emptyFrameAncestors = '';
99115
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
100116

101117
const configurationClient = {
102-
getEntityConfig: jest.fn().mockReturnValue(emptyCspRules),
118+
getEntityConfig: jest.fn().mockReturnValue(emptyFrameAncestors),
103119
};
104120

105121
const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);
@@ -125,17 +141,23 @@ describe('CSP handlers', () => {
125141
expect(toolkit.next).toHaveBeenCalledTimes(1);
126142
expect(toolkit.next).toHaveBeenCalledWith({
127143
headers: {
128-
'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML,
144+
'content-security-policy': "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'",
129145
},
130146
});
131147

132148
expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
149+
150+
expect(configurationClient.getEntityConfig).toBeCalledWith(
151+
CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY,
152+
{ headers: { 'sec-fetch-dest': 'document' } }
153+
);
154+
133155
expect(getConfigurationClient).toBeCalledWith(request);
134156
});
135157

136-
it('do not add CSP headers when the configuration does not exist and CSP from YML already has frame-ancestors', async () => {
158+
it('do not modify frame-ancestors when the configuration does not exist and CSP from YML already has frame-ancestors', async () => {
137159
const coreSetup = coreMock.createSetup();
138-
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'";
160+
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors localsystem";
139161

140162
const configurationClient = {
141163
getEntityConfig: jest.fn().mockImplementation(() => {
@@ -164,13 +186,23 @@ describe('CSP handlers', () => {
164186
expect(result).toEqual('next');
165187

166188
expect(toolkit.next).toBeCalledTimes(1);
167-
expect(toolkit.next).toBeCalledWith({});
189+
expect(toolkit.next).toBeCalledWith({
190+
headers: {
191+
'content-security-policy': cspRulesFromYML,
192+
},
193+
});
168194

169195
expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
196+
197+
expect(configurationClient.getEntityConfig).toBeCalledWith(
198+
CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY,
199+
{ headers: { 'sec-fetch-dest': 'document' } }
200+
);
201+
170202
expect(getConfigurationClient).toBeCalledWith(request);
171203
});
172204

173-
it('add frame-ancestors CSP headers when the configuration does not exist and CSP from YML has no frame-ancestors', async () => {
205+
it('add frame-ancestors when the configuration does not exist and CSP from YML has no frame-ancestors', async () => {
174206
const coreSetup = coreMock.createSetup();
175207
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
176208

@@ -199,15 +231,21 @@ describe('CSP handlers', () => {
199231
expect(toolkit.next).toBeCalledTimes(1);
200232
expect(toolkit.next).toBeCalledWith({
201233
headers: {
202-
'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML,
234+
'content-security-policy': "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'",
203235
},
204236
});
205237

206238
expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
239+
240+
expect(configurationClient.getEntityConfig).toBeCalledWith(
241+
CSP_RULES_FRAME_ANCESTORS_CONFIG_KEY,
242+
{ headers: { 'sec-fetch-dest': 'document' } }
243+
);
244+
207245
expect(getConfigurationClient).toBeCalledWith(request);
208246
});
209247

210-
it('do not add CSP headers when request dest exists and shall skip', async () => {
248+
it('do not add frame-ancestors when request dest exists and shall skip', async () => {
211249
const coreSetup = coreMock.createSetup();
212250
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
213251

@@ -243,7 +281,7 @@ describe('CSP handlers', () => {
243281
expect(getConfigurationClient).toBeCalledTimes(0);
244282
});
245283

246-
it('do not add CSP headers when request dest does not exist', async () => {
284+
it('do not add frame-ancestors when request dest does not exist', async () => {
247285
const coreSetup = coreMock.createSetup();
248286
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
249287

@@ -277,4 +315,76 @@ describe('CSP handlers', () => {
277315
expect(configurationClient.getEntityConfig).toBeCalledTimes(0);
278316
expect(getConfigurationClient).toBeCalledTimes(0);
279317
});
318+
319+
it('use default values when getting client throws error and CSP from YML has no frame-ancestors', async () => {
320+
const coreSetup = coreMock.createSetup();
321+
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";
322+
323+
const getConfigurationClient = jest.fn().mockImplementation(() => {
324+
throw new Error(ERROR_MESSAGE);
325+
});
326+
327+
const handler = createCspRulesPreResponseHandler(
328+
coreSetup,
329+
cspRulesFromYML,
330+
getConfigurationClient,
331+
logger
332+
);
333+
334+
const request = {
335+
method: 'get',
336+
headers: { 'sec-fetch-dest': 'document' },
337+
};
338+
339+
toolkit.next.mockReturnValue('next' as any);
340+
341+
const result = await handler(request, {} as any, toolkit);
342+
343+
expect(result).toEqual('next');
344+
345+
expect(toolkit.next).toHaveBeenCalledTimes(1);
346+
expect(toolkit.next).toHaveBeenCalledWith({
347+
headers: {
348+
'content-security-policy': "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'",
349+
},
350+
});
351+
352+
expect(getConfigurationClient).toBeCalledWith(request);
353+
});
354+
355+
it('do not modify when getting client throws error and CSP from YML has frame-ancestors', async () => {
356+
const coreSetup = coreMock.createSetup();
357+
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors localsystem";
358+
359+
const getConfigurationClient = jest.fn().mockImplementation(() => {
360+
throw new Error(ERROR_MESSAGE);
361+
});
362+
363+
const handler = createCspRulesPreResponseHandler(
364+
coreSetup,
365+
cspRulesFromYML,
366+
getConfigurationClient,
367+
logger
368+
);
369+
370+
const request = {
371+
method: 'get',
372+
headers: { 'sec-fetch-dest': 'document' },
373+
};
374+
375+
toolkit.next.mockReturnValue('next' as any);
376+
377+
const result = await handler(request, {} as any, toolkit);
378+
379+
expect(result).toEqual('next');
380+
381+
expect(toolkit.next).toHaveBeenCalledTimes(1);
382+
expect(toolkit.next).toHaveBeenCalledWith({
383+
headers: {
384+
'content-security-policy': cspRulesFromYML,
385+
},
386+
});
387+
388+
expect(getConfigurationClient).toBeCalledWith(request);
389+
});
280390
});

0 commit comments

Comments
 (0)