diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py
index 6204486456..d47277eaed 100644
--- a/awx/settings/defaults.py
+++ b/awx/settings/defaults.py
@@ -248,6 +248,7 @@ TEMPLATES = [
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
+ 'awx.ui.context_processors.csp',
'social_django.context_processors.backends',
'social_django.context_processors.login_redirect',
],
diff --git a/awx/ui/context_processors.py b/awx/ui/context_processors.py
new file mode 100644
index 0000000000..87c071c285
--- /dev/null
+++ b/awx/ui/context_processors.py
@@ -0,0 +1,8 @@
+import base64
+import os
+
+
+def csp(request):
+ return {
+ 'csp_nonce': base64.encodebytes(os.urandom(32)).decode().rstrip(),
+ }
diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json
index 252319a713..d223b47d29 100644
--- a/awx/ui_next/package.json
+++ b/awx/ui_next/package.json
@@ -53,7 +53,7 @@
},
"scripts": {
"start": "PORT=3001 HTTPS=true DANGEROUSLY_DISABLE_HOST_CHECK=true react-scripts start",
- "build": "react-scripts build",
+ "build": "INLINE_RUNTIME_CHUNK=false react-scripts build",
"test": "TZ='UTC' react-scripts test --coverage --watchAll=false",
"test-watch": "TZ='UTC' react-scripts test",
"eject": "react-scripts eject",
diff --git a/awx/ui_next/public/index.html b/awx/ui_next/public/index.html
index 2d7ff373b7..dc5174aa7c 100644
--- a/awx/ui_next/public/index.html
+++ b/awx/ui_next/public/index.html
@@ -1,6 +1,15 @@
+ <% if (process.env.NODE_ENV === 'production') { %>
+
+
+ <% } %>
@@ -12,6 +21,10 @@
-
+ <% if (process.env.NODE_ENV === 'production') { %>
+
+ <% } else { %>
+
+ <% } %>
diff --git a/awx/ui_next/src/index.jsx b/awx/ui_next/src/index.jsx
index ad616077ef..8bbeb9e866 100644
--- a/awx/ui_next/src/index.jsx
+++ b/awx/ui_next/src/index.jsx
@@ -1,5 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
+import './setupCSP';
import '@patternfly/react-core/dist/styles/base.css';
import App from './App';
import { BrandName } from './variables';
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
index 59908f695c..29f02e8245 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobEvent.jsx
@@ -1,6 +1,3 @@
-import Ansi from 'ansi-to-html';
-import hasAnsi from 'has-ansi';
-import { AllHtmlEntities } from 'html-entities';
import React from 'react';
import {
JobEventLine,
@@ -9,84 +6,18 @@ import {
JobEventLineText,
} from './shared';
-const EVENT_START_TASK = 'playbook_on_task_start';
-const EVENT_START_PLAY = 'playbook_on_play_start';
-const EVENT_STATS_PLAY = 'playbook_on_stats';
-const TIME_EVENTS = [EVENT_START_TASK, EVENT_START_PLAY, EVENT_STATS_PLAY];
-
-const ansi = new Ansi({
- stream: true,
- colors: {
- 0: '#000',
- 1: '#A30000',
- 2: '#486B00',
- 3: '#795600',
- 4: '#00A',
- 5: '#A0A',
- 6: '#004368',
- 7: '#AAA',
- 8: '#555',
- 9: '#F55',
- 10: '#5F5',
- 11: '#FF5',
- 12: '#55F',
- 13: '#F5F',
- 14: '#5FF',
- 15: '#FFF',
- },
-});
-const entities = new AllHtmlEntities();
-
-function getTimestamp({ created }) {
- const date = new Date(created);
-
- const dateHours = date.getHours();
- const dateMinutes = date.getMinutes();
- const dateSeconds = date.getSeconds();
-
- const stampHours = dateHours < 10 ? `0${dateHours}` : dateHours;
- const stampMinutes = dateMinutes < 10 ? `0${dateMinutes}` : dateMinutes;
- const stampSeconds = dateSeconds < 10 ? `0${dateSeconds}` : dateSeconds;
-
- return `${stampHours}:${stampMinutes}:${stampSeconds}`;
-}
-
-function getLineTextHtml({ created, event, start_line, stdout }) {
- const sanitized = entities.encode(stdout);
- return sanitized.split('\r\n').map((lineText, index) => {
- let html;
- if (hasAnsi(lineText)) {
- html = ansi.toHtml(lineText);
- } else {
- html = lineText;
- }
-
- if (index === 1 && TIME_EVENTS.includes(event)) {
- const time = getTimestamp({ created });
- html += `${time}`;
- }
-
- return {
- lineNumber: start_line + index,
- html,
- };
- });
-}
-
function JobEvent({
counter,
- created,
- event,
- isClickable,
- onJobEventClick,
stdout,
- start_line,
style,
type,
+ lineTextHtml,
+ isClickable,
+ onJobEventClick,
}) {
return !stdout ? null : (
- {getLineTextHtml({ created, event, start_line, stdout }).map(
+ {lineTextHtml.map(
({ lineNumber, html }) =>
lineNumber >= 0 && (
08:01:02',
+ },
+];
+
+const mockAnsiLineTextHtml = [
+ {
+ lineNumber: 4,
+ html: 'ok: [localhost]',
+ },
+];
+
+const mockOnPlayStartLineTextHtml = [
+ { lineNumber: 0, html: '' },
+ {
+ lineNumber: 1,
+ html:
+ 'PLAY [add hosts to inventory] **************************************************18:11:22',
+ },
+];
+
describe('', () => {
test('initially renders successfully', () => {
- mountWithContexts();
+ mountWithContexts(
+
+ );
});
test('playbook event timestamps are rendered', () => {
- let wrapper = mountWithContexts();
+ let wrapper = mountWithContexts(
+
+ );
let lineText = wrapper.find(selectors.lineText);
expect(
lineText.filterWhere(e => e.text().includes('18:11:22'))
).toHaveLength(1);
- const singleDigitTimestampEvent = {
- ...mockOnPlayStartEvent,
- created: '2019-07-11T08:01:02.906001Z',
- };
- wrapper = mountWithContexts();
+ wrapper = mountWithContexts(
+
+ );
lineText = wrapper.find(selectors.lineText);
expect(
lineText.filterWhere(e => e.text().includes('08:01:02'))
@@ -48,12 +88,14 @@ describe('', () => {
});
test('ansi stdout colors are rendered as html', () => {
- const wrapper = mountWithContexts();
+ const wrapper = mountWithContexts(
+
+ );
const lineText = wrapper.find(selectors.lineText);
expect(
lineText
.html()
- .includes('ok: [localhost]')
+ .includes('ok: [localhost]')
).toBe(true);
});
diff --git a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
index 9c6b324891..4d78d3b364 100644
--- a/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
+++ b/awx/ui_next/src/screens/Job/JobOutput/JobOutput.jsx
@@ -1,4 +1,4 @@
-import React, { Component } from 'react';
+import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@@ -10,6 +10,9 @@ import {
InfiniteLoader,
List,
} from 'react-virtualized';
+import Ansi from 'ansi-to-html';
+import hasAnsi from 'has-ansi';
+import { AllHtmlEntities } from 'html-entities';
import AlertModal from '../../../components/AlertModal';
import { CardBody } from '../../../components/Card';
@@ -32,6 +35,106 @@ import {
AdHocCommandsAPI,
} from '../../../api';
+const EVENT_START_TASK = 'playbook_on_task_start';
+const EVENT_START_PLAY = 'playbook_on_play_start';
+const EVENT_STATS_PLAY = 'playbook_on_stats';
+const TIME_EVENTS = [EVENT_START_TASK, EVENT_START_PLAY, EVENT_STATS_PLAY];
+
+const ansi = new Ansi({
+ stream: true,
+ colors: {
+ 0: '#000',
+ 1: '#A30000',
+ 2: '#486B00',
+ 3: '#795600',
+ 4: '#00A',
+ 5: '#A0A',
+ 6: '#004368',
+ 7: '#AAA',
+ 8: '#555',
+ 9: '#F55',
+ 10: '#5F5',
+ 11: '#FF5',
+ 12: '#55F',
+ 13: '#F5F',
+ 14: '#5FF',
+ 15: '#FFF',
+ },
+});
+const entities = new AllHtmlEntities();
+
+function getTimestamp({ created }) {
+ const date = new Date(created);
+
+ const dateHours = date.getHours();
+ const dateMinutes = date.getMinutes();
+ const dateSeconds = date.getSeconds();
+
+ const stampHours = dateHours < 10 ? `0${dateHours}` : dateHours;
+ const stampMinutes = dateMinutes < 10 ? `0${dateMinutes}` : dateMinutes;
+ const stampSeconds = dateSeconds < 10 ? `0${dateSeconds}` : dateSeconds;
+
+ return `${stampHours}:${stampMinutes}:${stampSeconds}`;
+}
+
+const styleAttrPattern = new RegExp('style="[^"]*"', 'g');
+
+function createStyleAttrHash(styleAttr) {
+ let hash = 0;
+ for (let i = 0; i < styleAttr.length; i++) {
+ hash = (hash << 5) - hash; // eslint-disable-line no-bitwise
+ hash += styleAttr.charCodeAt(i);
+ hash &= hash; // eslint-disable-line no-bitwise
+ }
+ return `${hash}`;
+}
+
+function replaceStyleAttrs(html) {
+ const allStyleAttrs = [...new Set(html.match(styleAttrPattern))];
+ const cssMap = {};
+ let result = html;
+ for (let i = 0; i < allStyleAttrs.length; i++) {
+ const styleAttr = allStyleAttrs[i];
+ const cssClassName = `output-${createStyleAttrHash(styleAttr)}`;
+
+ cssMap[cssClassName] = styleAttr.replace('style="', '').slice(0, -1);
+ result = result.split(styleAttr).join(`class="${cssClassName}"`);
+ }
+ return { cssMap, result };
+}
+
+function getLineTextHtml({ created, event, start_line, stdout }) {
+ const sanitized = entities.encode(stdout);
+ let lineCssMap = {};
+ const lineTextHtml = [];
+
+ sanitized.split('\r\n').forEach((lineText, index) => {
+ let html;
+ if (hasAnsi(lineText)) {
+ const { cssMap, result } = replaceStyleAttrs(ansi.toHtml(lineText));
+ html = result;
+ lineCssMap = { ...lineCssMap, ...cssMap };
+ } else {
+ html = lineText;
+ }
+
+ if (index === 1 && TIME_EVENTS.includes(event)) {
+ const time = getTimestamp({ created });
+ html += `${time}`;
+ }
+
+ lineTextHtml.push({
+ lineNumber: start_line + index,
+ html,
+ });
+ });
+
+ return {
+ lineCssMap,
+ lineTextHtml,
+ };
+}
+
const HeaderTitle = styled.div`
display: inline-flex;
align-items: center;
@@ -54,6 +157,8 @@ const OutputWrapper = styled.div`
font-size: 15px;
height: calc(100vh - 350px);
outline: 1px solid #d7d7d7;
+ ${({ cssMap }) =>
+ Object.keys(cssMap).map(className => `.${className}{${cssMap[className]}}`)}
`;
const OutputFooter = styled.div`
@@ -122,6 +227,7 @@ class JobOutput extends Component {
remoteRowCount: 0,
isHostModalOpen: false,
hostEvent: {},
+ cssMap: {},
};
this.cache = new CellMeasurerCache({
@@ -164,7 +270,7 @@ class JobOutput extends Component {
componentDidUpdate(prevProps, prevState) {
// recompute row heights for any job events that have transitioned
// from loading to loaded
- const { currentlyLoading } = this.state;
+ const { currentlyLoading, cssMap } = this.state;
let shouldRecomputeRowHeights = false;
prevState.currentlyLoading
.filter(n => !currentlyLoading.includes(n))
@@ -172,6 +278,9 @@ class JobOutput extends Component {
shouldRecomputeRowHeights = true;
this.cache.clear(n);
});
+ if (Object.keys(cssMap).length !== Object.keys(prevState.cssMap).length) {
+ shouldRecomputeRowHeights = true;
+ }
if (shouldRecomputeRowHeights) {
if (this.listRef.recomputeRowHeights) {
this.listRef.recomputeRowHeights();
@@ -300,6 +409,13 @@ class JobOutput extends Component {
return isHost;
};
+ let actualLineTextHtml = [];
+ if (results[index]) {
+ const { lineTextHtml, lineCssMap } = getLineTextHtml(results[index]);
+ this.setState(({ cssMap }) => ({ cssMap: { ...cssMap, ...lineCssMap } }));
+ actualLineTextHtml = lineTextHtml;
+ }
+
return (
this.handleHostEventClick(results[index])}
className="row"
style={style}
+ lineTextHtml={actualLineTextHtml}
{...results[index]}
/>
) : (
@@ -389,7 +506,9 @@ class JobOutput extends Component {
handleResize({ width }) {
if (width !== this._previousWidth) {
this.cache.clearAll();
- this.listRef.recomputeRowHeights();
+ if (this.listRef?.recomputeRowHeights) {
+ this.listRef.recomputeRowHeights();
+ }
}
this._previousWidth = width;
}
@@ -404,6 +523,7 @@ class JobOutput extends Component {
hostEvent,
isHostModalOpen,
remoteRowCount,
+ cssMap,
} = this.state;
if (hasContentLoading) {
@@ -415,60 +535,62 @@ class JobOutput extends Component {
}
return (
-
- {isHostModalOpen && (
-
+
+ {isHostModalOpen && (
+
+ )}
+
+
+
+ {job.name}
+
+
+
+
+
- )}
-
-
-
- {job.name}
-
-
-
-
-
-
-
- {({ onRowsRendered, registerChild }) => (
-
- {({ width, height }) => {
- return (
- {
- this.listRef = ref;
- registerChild(ref);
- }}
- deferredMeasurementCache={this.cache}
- height={height || 1}
- onRowsRendered={onRowsRendered}
- rowCount={remoteRowCount}
- rowHeight={this.cache.rowHeight}
- rowRenderer={this.rowRenderer}
- scrollToAlignment="start"
- width={width || 1}
- overscanRowCount={20}
- />
- );
- }}
-
- )}
-
-
-
+
+
+ {({ onRowsRendered, registerChild }) => (
+
+ {({ width, height }) => {
+ return (
+ {
+ this.listRef = ref;
+ registerChild(ref);
+ }}
+ deferredMeasurementCache={this.cache}
+ height={height || 1}
+ onRowsRendered={onRowsRendered}
+ rowCount={remoteRowCount}
+ rowHeight={this.cache.rowHeight}
+ rowRenderer={this.rowRenderer}
+ scrollToAlignment="start"
+ width={width || 1}
+ overscanRowCount={20}
+ />
+ );
+ }}
+
+ )}
+
+
+
+
{deletionError && (
)}
-
+
);
}
}
diff --git a/awx/ui_next/src/setupCSP.js b/awx/ui_next/src/setupCSP.js
new file mode 100644
index 0000000000..77d40c5775
--- /dev/null
+++ b/awx/ui_next/src/setupCSP.js
@@ -0,0 +1,30 @@
+/* eslint-disable */
+
+// Set a special variable to add `nonce` attributes to all styles/script tags
+// See https://github.com/webpack/webpack/pull/3210
+__webpack_nonce__ = window.NONCE_ID;
+
+// Send report when a CSP violation occurs
+// See: https://w3c.github.io/webappsec-csp/2/#violation-reports
+// See: https://developer.mozilla.org/en-US/docs/Web/API/SecurityPolicyViolationEvent
+document.addEventListener('securitypolicyviolation', e => {
+ const violation = {
+ 'csp-report': {
+ 'blocked-uri': e.blockedURI,
+ 'document-uri': e.documentURI,
+ 'effective-directive': e.effectiveDirective,
+ 'original-policy': e.originalPolicy,
+ referrer: e.referrer,
+ 'status-code': e.statusCode,
+ 'violated-directive': e.violatedDirective,
+ },
+ };
+ if (e.sourceFile) violation['csp-report']['source-file'] = e.sourceFile;
+ if (e.lineNumber) violation['csp-report']['line-number'] = e.lineNumber;
+ if (e.columnNumber) violation['csp-report']['column-number'] = e.columnNumber;
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', '/csp-violation/', true);
+ xhr.setRequestHeader('content-type', 'application/csp-report');
+ xhr.send(JSON.stringify(violation));
+});
diff --git a/awx/ui_next/src/setupTests.js b/awx/ui_next/src/setupTests.js
index 7d59ff1a4c..5518d62c93 100644
--- a/awx/ui_next/src/setupTests.js
+++ b/awx/ui_next/src/setupTests.js
@@ -19,3 +19,8 @@ global.console = {
...console,
debug: jest.fn(),
};
+
+// This global variable is part of our Content Security Policy framework
+// and so this mock ensures that we don't encounter a reference error
+// when running the tests
+global.__webpack_nonce__ = null;
diff --git a/installer/roles/kubernetes/templates/configmap.yml.j2 b/installer/roles/kubernetes/templates/configmap.yml.j2
index b7553811c1..b239b96783 100644
--- a/installer/roles/kubernetes/templates/configmap.yml.j2
+++ b/installer/roles/kubernetes/templates/configmap.yml.j2
@@ -69,8 +69,6 @@ data:
# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
add_header Strict-Transport-Security max-age=15768000;
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
- add_header X-Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
# Protect against click-jacking https://www.owasp.org/index.php/Testing_for_Clickjacking_(OTG-CLIENT-009)
add_header X-Frame-Options "DENY";
diff --git a/installer/roles/local_docker/templates/nginx.conf.j2 b/installer/roles/local_docker/templates/nginx.conf.j2
index 0c93510bc9..327b59a2fe 100644
--- a/installer/roles/local_docker/templates/nginx.conf.j2
+++ b/installer/roles/local_docker/templates/nginx.conf.j2
@@ -67,8 +67,6 @@ http {
# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
add_header Strict-Transport-Security max-age=15768000;
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
- add_header X-Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
# Protect against click-jacking https://www.owasp.org/index.php/Testing_for_Clickjacking_(OTG-CLIENT-009)
add_header X-Frame-Options "DENY";
diff --git a/tools/docker-compose/nginx.vh.default.conf b/tools/docker-compose/nginx.vh.default.conf
index ff7f604b5e..73a4d1cd8d 100644
--- a/tools/docker-compose/nginx.vh.default.conf
+++ b/tools/docker-compose/nginx.vh.default.conf
@@ -22,8 +22,6 @@ server {
# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
add_header Strict-Transport-Security max-age=15768000;
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
- add_header X-Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
location /static/ {
root /awx_devel;
@@ -84,8 +82,6 @@ server {
# HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months)
add_header Strict-Transport-Security max-age=15768000;
- add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
- add_header X-Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' *.pendo.io; img-src 'self' *.pendo.io data:; report-uri /csp-violation/";
location /static/ {
root /awx_devel;