Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support dynamic CSP rules to mitigate clickjacking #5641

Merged
merged 59 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6376379
support dynamic csp rules to mitigate clickjacking
tianleh Dec 23, 2023
a6c0f6e
add unit tests for the provider class
tianleh Dec 27, 2023
c4481c1
move request handler to its own class
tianleh Dec 27, 2023
e97bef8
add license headers
tianleh Dec 27, 2023
3343fe4
fix failed unit tests
tianleh Dec 27, 2023
f634d39
add unit tests for the handler
tianleh Dec 28, 2023
4e03159
add content to read me
tianleh Dec 28, 2023
d9f9bac
fix test error
tianleh Dec 28, 2023
7a4a93f
update readme
tianleh Dec 28, 2023
5515cad
update CHANGELOG.md
tianleh Dec 28, 2023
f0a7bf0
update snap tests
tianleh Dec 28, 2023
352431a
update snapshots
tianleh Dec 28, 2023
8ac1824
fix a wrong import
tianleh Dec 29, 2023
8688156
undo changes in listing snap
tianleh Dec 29, 2023
d54f8e4
improve wording
tianleh Dec 29, 2023
9c6a8e9
set client after default client is created
tianleh Jan 2, 2024
f570ecc
update return value and add a unit test
tianleh Jan 3, 2024
9e0d78d
remove unnecessary dependency
tianleh Jan 6, 2024
d42ff1e
make the name of the index configurable
tianleh Jan 8, 2024
29211c9
expose APIs and update file structures
tianleh Jan 18, 2024
48ee280
add header
tianleh Jan 18, 2024
ce1b89a
fix link error
tianleh Jan 18, 2024
fe140d8
fix link error
tianleh Jan 18, 2024
bd9ff1d
add more unit tests
tianleh Jan 18, 2024
9e14248
add more unit tests
tianleh Jan 18, 2024
fca9738
update api path
tianleh Jan 19, 2024
6f2e0d4
remove logging
tianleh Jan 19, 2024
60c89b5
update path
tianleh Jan 25, 2024
e9bfdc6
rename index name
tianleh Jan 25, 2024
f4d9f63
update wording
tianleh Jan 25, 2024
e4efc0e
make the new plugin disabled by default
tianleh Jan 30, 2024
b8be888
do not update defaults to avoid breaking change
tianleh Jan 30, 2024
e588758
update readme to reflect new API path
tianleh Jan 31, 2024
6bc00ce
update handler to append frame-ancestors conditionally
tianleh Feb 3, 2024
8f68228
update readme
tianleh Feb 6, 2024
4dbcc21
clean up code to prepare for application config
tianleh Feb 29, 2024
22e5394
reset change log
tianleh Feb 29, 2024
27ce0b1
reset change log again
tianleh Feb 29, 2024
d55b459
update accordingly to new changes in applicationConfig
tianleh Mar 1, 2024
9f371e7
update changelog
tianleh Mar 1, 2024
54a5db1
rename to a new plugin name
tianleh Mar 1, 2024
0e9e146
rename
tianleh Mar 1, 2024
b434169
rename more
tianleh Mar 1, 2024
e67af3c
sync changelog from main
tianleh Mar 4, 2024
2d12575
onboard to app config
tianleh Mar 4, 2024
ef3a55d
fix comment
tianleh Mar 4, 2024
f2ac166
update yml
tianleh Mar 5, 2024
f8dc3b4
update readme
tianleh Mar 5, 2024
0c6a1cc
update change log
tianleh Mar 5, 2024
78004c9
call out single quotes in readme
tianleh Mar 5, 2024
3dc2024
update yml
tianleh Mar 5, 2024
140375f
update default
tianleh Mar 5, 2024
e31cc9b
add reference link
tianleh Mar 5, 2024
688f65a
update js doc
tianleh Mar 5, 2024
c99b02f
rename
tianleh Mar 5, 2024
68f3ea2
use new name
tianleh Mar 5, 2024
8c8eb0f
redo changelog update
tianleh Mar 6, 2024
78d8b32
remove link
tianleh Mar 6, 2024
fcc9875
better name
tianleh Mar 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
### Deprecations

### 🛡 Security
- Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641

### 📈 Features/Enhancements
- [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042))
Expand Down
4 changes: 4 additions & 0 deletions config/opensearch_dashboards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@
# Set the value of this setting to true to enable plugin application config. By default it is disabled.
# application_config.enabled: false

# Set the value of this setting to true to enable plugin CSP handler. By default it is disabled.
# It requires the application config plugin as its dependency.
# csp_handler.enabled: false
bandinib-amzn marked this conversation as resolved.
Show resolved Hide resolved

# The default application to load.
#opensearchDashboards.defaultAppId: "home"

Expand Down
6 changes: 5 additions & 1 deletion src/plugins/application_config/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ export function plugin(initializerContext: PluginInitializerContext) {
return new ApplicationConfigPlugin(initializerContext);
}

export { ApplicationConfigPluginSetup, ApplicationConfigPluginStart } from './types';
export {
ApplicationConfigPluginSetup,
tianleh marked this conversation as resolved.
Show resolved Hide resolved
ApplicationConfigPluginStart,
ConfigurationClient,
} from './types';
51 changes: 51 additions & 0 deletions src/plugins/csp_handler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# CspHandler

A OpenSearch Dashboards plugin

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.

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.
bandinib-amzn marked this conversation as resolved.
Show resolved Hide resolved

---

## Configuration

The plugin can be enabled by adding this line in OSD YML.

```
csp_handler.enabled: true

```

Since it has a required dependency `applicationConfig`, make sure that the dependency is also enabled.

```
application_config.enabled: true
```

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 `'`**

```
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}"}'

```

Below is the CURL command to delete CSP rules.

```
curl '{osd endpoint}/api/appconfig/csp.rules' -X DELETE -H 'osd-xsrf: osd-fetch' -H 'Sec-Fetch-Dest: empty'
```

Below is the CURL command to get the CSP rules.

```
curl '{osd endpoint}/api/appconfig/csp.rules'

```

---
## Development

See the [OpenSearch Dashboards contributing
guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions
setting up your development environment.
7 changes: 7 additions & 0 deletions src/plugins/csp_handler/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export const PLUGIN_ID = 'cspHandler';
export const PLUGIN_NAME = 'CspHandler';
12 changes: 12 additions & 0 deletions src/plugins/csp_handler/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { schema, TypeOf } from '@osd/config-schema';

export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
});

export type CspHandlerConfigSchema = TypeOf<typeof configSchema>;
11 changes: 11 additions & 0 deletions src/plugins/csp_handler/opensearch_dashboards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "cspHandler",
"version": "opensearchDashboards",
"opensearchDashboardsVersion": "opensearchDashboards",
"server": true,
"ui": false,
"requiredPlugins": [
"applicationConfig"
],
"optionalPlugins": []
}
273 changes: 273 additions & 0 deletions src/plugins/csp_handler/server/csp_handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { coreMock, httpServerMock } from '../../../core/server/mocks';
import { createCspRulesPreResponseHandler } from './csp_handlers';
import { MockedLogger, loggerMock } from '@osd/logging/target/mocks';

const ERROR_MESSAGE = 'Service unavailable';

describe('CSP handlers', () => {
let toolkit: ReturnType<typeof httpServerMock.createToolkit>;
let logger: MockedLogger;

beforeEach(() => {
toolkit = httpServerMock.createToolkit();
logger = loggerMock.create();
});

it('adds the CSP headers provided by the client', async () => {
const coreSetup = coreMock.createSetup();
const cspRulesFromIndex = "frame-ancestors 'self'";
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'";

const configurationClient = {
getEntityConfig: jest.fn().mockReturnValue(cspRulesFromIndex),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);
const request = {
method: 'get',
headers: { 'sec-fetch-dest': 'document' },
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toHaveBeenCalledTimes(1);

expect(toolkit.next).toHaveBeenCalledWith({
headers: {
'content-security-policy': cspRulesFromIndex,
},
});

expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
});

it('do not add CSP headers when the client returns empty and CSP from YML already has frame-ancestors', async () => {
const coreSetup = coreMock.createSetup();
const emptyCspRules = '';
const cspRulesFromYML = "script-src 'unsafe-eval' 'self'; frame-ancestors 'self'";

const configurationClient = {
getEntityConfig: jest.fn().mockReturnValue(emptyCspRules),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);
const request = {
method: 'get',
headers: { 'sec-fetch-dest': 'document' },
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({});

expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
});

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

const configurationClient = {
getEntityConfig: jest.fn().mockReturnValue(emptyCspRules),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);

const request = {
method: 'get',
headers: { 'sec-fetch-dest': 'document' },
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toHaveBeenCalledTimes(1);
expect(toolkit.next).toHaveBeenCalledWith({
headers: {
'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML,
},
});

expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
});

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

const configurationClient = {
getEntityConfig: jest.fn().mockImplementation(() => {
throw new Error(ERROR_MESSAGE);
}),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);

const request = {
method: 'get',
headers: { 'sec-fetch-dest': 'document' },
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toBeCalledTimes(1);
expect(toolkit.next).toBeCalledWith({});

expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
});

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

const configurationClient = {
getEntityConfig: jest.fn().mockImplementation(() => {
throw new Error(ERROR_MESSAGE);
}),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);
const request = { method: 'get', headers: { 'sec-fetch-dest': 'document' } };

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toBeCalledTimes(1);
expect(toolkit.next).toBeCalledWith({
headers: {
'content-security-policy': "frame-ancestors 'self'; " + cspRulesFromYML,
},
});

expect(configurationClient.getEntityConfig).toBeCalledTimes(1);
});

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

const configurationClient = {
getEntityConfig: jest.fn(),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);

const cssSecFetchDest = 'css';
const request = {
method: 'get',
headers: { 'sec-fetch-dest': cssSecFetchDest },
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toBeCalledTimes(1);
expect(toolkit.next).toBeCalledWith({});

expect(configurationClient.getEntityConfig).toBeCalledTimes(0);
});

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

const configurationClient = {
getEntityConfig: jest.fn(),
};

const getConfigurationClient = jest.fn().mockReturnValue(configurationClient);

const handler = createCspRulesPreResponseHandler(
coreSetup,
cspRulesFromYML,
getConfigurationClient,
logger
);

const request = {
method: 'get',
headers: {},
};

toolkit.next.mockReturnValue('next' as any);

const result = await handler(request, {} as any, toolkit);

expect(result).toEqual('next');

expect(toolkit.next).toBeCalledTimes(1);
expect(toolkit.next).toBeCalledWith({});

expect(configurationClient.getEntityConfig).toBeCalledTimes(0);
});
});
Loading
Loading