Skip to content

Commit

Permalink
Move CSS in a separate file to be CSP-compliant (#6048)
Browse files Browse the repository at this point in the history
In order to be compatible with any CSP, we need to prevent the automatic creation of the DOM 'style' element and offer our CSS as a separate file that can be manually loaded (`Chart.js` or `Chart.min.js`). Users can now opt-out the style injection using `Chart.platform.disableCSSInjection = true` (note that the style sheet is now injected on the first chart creation).

To prevent duplicating and maintaining the same CSS code at different places, move all these rules in `platform.dom.css` and write a minimal rollup plugin to inject that style as string in `platform.dom.js`. Additionally, this plugin extract the imported style in `./dist/Chart.js` and `./dist/Chart.min.js`.
  • Loading branch information
simonbrunel committed Feb 8, 2019
1 parent c6c4db7 commit 55128f7
Show file tree
Hide file tree
Showing 14 changed files with 266 additions and 50 deletions.
15 changes: 15 additions & 0 deletions docs/getting-started/integration.md
Expand Up @@ -84,3 +84,18 @@ require(['moment'], function() {
});
});
```

## Content Security Policy

By default, Chart.js injects CSS directly into the DOM. For webpages secured using [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP), this requires to allow `style-src 'unsafe-inline'`. For stricter CSP environments, where only `style-src 'self'` is allowed, the following CSS file needs to be manually added to your webpage:

```html
<link rel="stylesheet" type="text/css" href="path/to/chartjs/dist/Chart.min.css">
```

And the style injection must be turned off **before creating the first chart**:

```javascript
// Disable automatic style injection
Chart.platform.disableCSSInjection = true;
```
2 changes: 1 addition & 1 deletion gulpfile.js
Expand Up @@ -86,7 +86,7 @@ function buildTask() {
function packageTask() {
return merge(
// gather "regular" files landing in the package root
gulp.src([outDir + '*.js', 'LICENSE.md']),
gulp.src([outDir + '*.js', outDir + '*.css', 'LICENSE.md']),

// since we moved the dist files one folder up (package root), we need to rewrite
// samples src="../dist/ to src="../ and then copy them in the /samples directory.
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -23,6 +23,7 @@
"url": "https://github.com/chartjs/Chart.js/issues"
},
"devDependencies": {
"clean-css": "^4.2.1",
"coveralls": "^3.0.0",
"eslint": "^5.9.0",
"eslint-config-chartjs": "^0.1.0",
Expand Down
14 changes: 13 additions & 1 deletion rollup.config.js
Expand Up @@ -4,6 +4,7 @@ const commonjs = require('rollup-plugin-commonjs');
const resolve = require('rollup-plugin-node-resolve');
const terser = require('rollup-plugin-terser').terser;
const optional = require('./rollup.plugins').optional;
const stylesheet = require('./rollup.plugins').stylesheet;
const pkg = require('./package.json');

const input = 'src/chart.js';
Expand All @@ -23,6 +24,9 @@ module.exports = [
plugins: [
resolve(),
commonjs(),
stylesheet({
extract: true
}),
optional({
include: ['moment']
})
Expand All @@ -49,6 +53,10 @@ module.exports = [
optional({
include: ['moment']
}),
stylesheet({
extract: true,
minify: true
}),
terser({
output: {
preamble: banner
Expand Down Expand Up @@ -76,7 +84,8 @@ module.exports = [
input: input,
plugins: [
resolve(),
commonjs()
commonjs(),
stylesheet()
],
output: {
name: 'Chart',
Expand All @@ -91,6 +100,9 @@ module.exports = [
plugins: [
resolve(),
commonjs(),
stylesheet({
minify: true
}),
terser({
output: {
preamble: banner
Expand Down
49 changes: 48 additions & 1 deletion rollup.plugins.js
@@ -1,4 +1,6 @@
/* eslint-env es6 */
const cleancss = require('clean-css');
const path = require('path');

const UMD_WRAPPER_RE = /(\(function \(global, factory\) \{)((?:\s.*?)*)(\}\(this,)/;
const CJS_FACTORY_RE = /(module.exports = )(factory\(.*?\))( :)/;
Expand Down Expand Up @@ -56,6 +58,51 @@ function optional(config = {}) {
};
}

// https://github.com/chartjs/Chart.js/issues/5208
function stylesheet(config = {}) {
const minifier = new cleancss();
const styles = [];

return {
name: 'stylesheet',
transform(code, id) {
// Note that 'id' can be mapped to a CJS proxy import, in which case
// 'id' will start with 'commonjs-proxy', so let's first check if we
// are importing an existing css file (i.e. startsWith()).
if (!id.startsWith(path.resolve('.')) || !id.endsWith('.css')) {
return;
}

if (config.minify) {
code = minifier.minify(code).styles;
}

// keep track of all imported stylesheets (already minified)
styles.push(code);

return {
code: 'export default ' + JSON.stringify(code)
};
},
generateBundle(opts, bundle) {
if (!config.extract) {
return;
}

const entry = Object.keys(bundle).find(v => bundle[v].isEntry);
const name = (entry || '').replace(/\.js$/i, '.css');
if (!name) {
this.error('failed to guess the output file name');
}

bundle[name] = {
code: styles.filter(v => !!v).join('')
};
}
};
}

module.exports = {
optional
optional,
stylesheet
};
20 changes: 20 additions & 0 deletions samples/advanced/content-security-policy.css
@@ -0,0 +1,20 @@
.content {
max-width: 640px;
margin: auto;
padding: 1rem;
}

.note {
font-family: sans-serif;
color: #5050a0;
line-height: 1.4;
margin-bottom: 1rem;
padding: 1rem;
}

code {
background-color: #f5f5ff;
border: 1px solid #d0d0fa;
border-radius: 4px;
padding: 0.05rem 0.25rem;
}
27 changes: 27 additions & 0 deletions samples/advanced/content-security-policy.html
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=Edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
<title>Scriptable > Bubble | Chart.js sample</title>
<link rel="stylesheet" type="text/css" href="../../dist/Chart.min.css">
<link rel="stylesheet" type="text/css" href="./content-security-policy.css">
<script src="../../dist/Chart.min.js"></script>
<script src="../utils.js"></script>
<script src="content-security-policy.js"></script>
</head>
<body>
<div class="content">
<div class="note">
In order to support a strict content security policy (<code>default-src 'self'</code>),
this page manually loads <code>Chart.min.css</code> and turns off the automatic style
injection by setting <code>Chart.platform.disableCSSInjection = true;</code>.
</div>
<div class="wrapper">
<canvas id="chart-0"></canvas>
</div>
</div>
</body>
</html>
54 changes: 54 additions & 0 deletions samples/advanced/content-security-policy.js
@@ -0,0 +1,54 @@
var utils = Samples.utils;

// CSP: disable automatic style injection
Chart.platform.disableCSSInjection = true;

utils.srand(110);

function generateData() {
var DATA_COUNT = 16;
var MIN_XY = -150;
var MAX_XY = 100;
var data = [];
var i;

for (i = 0; i < DATA_COUNT; ++i) {
data.push({
x: utils.rand(MIN_XY, MAX_XY),
y: utils.rand(MIN_XY, MAX_XY),
v: utils.rand(0, 1000)
});
}

return data;
}

window.addEventListener('load', function() {
new Chart('chart-0', {
type: 'bubble',
data: {
datasets: [{
backgroundColor: utils.color(0),
data: generateData()
}, {
backgroundColor: utils.color(1),
data: generateData()
}]
},
options: {
aspectRatio: 1,
legend: false,
tooltip: false,
elements: {
point: {
radius: function(context) {
var value = context.dataset.data[context.dataIndex];
var size = context.chart.width;
var base = Math.abs(value.v) / 1000;
return (size / 24) * base;
}
}
}
}
});
});
3 changes: 3 additions & 0 deletions samples/samples.js
Expand Up @@ -187,6 +187,9 @@
items: [{
title: 'Progress bar',
path: 'advanced/progress-bar.html'
}, {
title: 'Content Security Policy',
path: 'advanced/content-security-policy.html'
}]
}];

Expand Down
1 change: 0 additions & 1 deletion samples/style.css
@@ -1,4 +1,3 @@
@import url('https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css');
@import url('https://fonts.googleapis.com/css?family=Lato:100,300,400,700,900');

body, html {
Expand Down
2 changes: 1 addition & 1 deletion scripts/deploy.sh
Expand Up @@ -41,7 +41,7 @@ cd $TARGET_DIR
git checkout $TARGET_BRANCH

# Copy dist files
deploy_files '../dist/*.js' './dist'
deploy_files '../dist/*.css ../dist/*.js' './dist'

# Copy generated documentation
deploy_files '../dist/docs/*' './docs'
Expand Down
2 changes: 1 addition & 1 deletion scripts/release.sh
Expand Up @@ -21,7 +21,7 @@ git remote add auth-origin https://$GITHUB_AUTH_TOKEN@github.com/$TRAVIS_REPO_SL
git config --global user.email "$GITHUB_AUTH_EMAIL"
git config --global user.name "Chart.js"
git checkout --detach --quiet
git add -f dist/*.js bower.json
git add -f dist/*.css dist/*.js bower.json
git commit -m "Release $VERSION"
git tag -a "v$VERSION" -m "Version $VERSION"
git push -q auth-origin refs/tags/v$VERSION 2>/dev/null
Expand Down
46 changes: 46 additions & 0 deletions src/platforms/platform.dom.css
@@ -0,0 +1,46 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}

.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}

/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}

.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}

.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}

0 comments on commit 55128f7

Please sign in to comment.