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;