Add CSP whitelist to the security settings

closes CORE-2109

Test Plan:
  - Enable the Content Security Policy feature flag
  - Go to account settings, security tab
  - Use the Add Domain form to add a domain
  - It should appear on the whitelist
  - Flip the CSP switch to enabled

Change-Id: Iaea578c52e9b8bb6541a4e13edc6a1702e907cdf
Reviewed-on: https://gerrit.instructure.com/174980
Tested-by: Jenkins
QA-Review: Tucker Mcknight <tmcknight@instructure.com>
Product-Review: Clay Diffrient <cdiffrient@instructure.com>
Reviewed-by: Brent Burgoyne <bburgoyne@instructure.com>
This commit is contained in:
Clay Diffrient 2018-12-10 22:51:31 -07:00
parent a2a7de4bc1
commit 3938ab7550
11 changed files with 527 additions and 14 deletions

View File

@ -1,5 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`addDomainAction creates an ADD_DOMAIN action when passed a boolean value 1`] = `
Object {
"payload": "instructure.com",
"type": "ADD_DOMAIN",
}
`;
exports[`addDomainAction creates an ADD_DOMAIN_OPTIMISTIC action when optimistic option is given 1`] = `
Object {
"payload": "instructure.com",
"type": "ADD_DOMAIN_OPTIMISTIC",
}
`;
exports[`addDomainAction creates an error action if passed a non-string value 1`] = `
Object {
"error": true,
"payload": [Error: Can only set to String values],
"type": "ADD_DOMAIN",
}
`;
exports[`addDomainBulkAction creates a ADD_DOMAIN action when passed an value 1`] = `
Object {
"payload": Array [
"instructure.com",
"canvaslms.com",
],
"type": "ADD_DOMAIN_BULK",
}
`;
exports[`addDomainBulkAction creates an error action if passed a non-Array value 1`] = `
Object {
"error": true,
"payload": [Error: Can only set to an array of strings],
"type": "ADD_DOMAIN_BULK",
}
`;
exports[`setCspEnabledAction creates a SET_CSP_ENABLED action when passed a boolean value 1`] = `
Object {
"payload": true,

View File

@ -107,3 +107,100 @@ describe('getCspEnabled', () => {
})
})
})
describe('addDomainAction', () => {
it('creates an ADD_DOMAIN action when passed a boolean value', () => {
expect(Actions.addDomainAction('instructure.com')).toMatchSnapshot()
})
it('creates an error action if passed a non-string value', () => {
expect(Actions.addDomainAction(true)).toMatchSnapshot()
})
it('creates an ADD_DOMAIN_OPTIMISTIC action when optimistic option is given', () => {
expect(Actions.addDomainAction('instructure.com', {optimistic: true})).toMatchSnapshot()
})
})
describe('addDomain', () => {
it('dispatches an optimistic action followed by the final result', () => {
const thunk = Actions.addDomain('account', 1, 'instructure.com')
const fakeDispatch = jest.fn()
const fakeAxios = {
post: jest.fn(() => ({
then(func) {
const fakeResponse = {}
func(fakeResponse)
}
}))
}
thunk(fakeDispatch, null, {axios: fakeAxios})
expect(fakeDispatch).toHaveBeenNthCalledWith(1, {
payload: 'instructure.com',
type: 'ADD_DOMAIN_OPTIMISTIC'
})
expect(fakeDispatch).toHaveBeenNthCalledWith(2, {
payload: 'instructure.com',
type: 'ADD_DOMAIN'
})
})
})
describe('addDomainBulkAction', () => {
it('creates a ADD_DOMAIN action when passed an value', () => {
expect(Actions.addDomainBulkAction(['instructure.com', 'canvaslms.com'])).toMatchSnapshot()
})
it('creates an error action if passed a non-Array value', () => {
expect(Actions.addDomainBulkAction('instructure.com')).toMatchSnapshot()
})
})
describe('getCurrentWhitelist', () => {
it('dispatches a bulk domain action using the effective_whitelist when the whitelist is enabled', () => {
const thunk = Actions.getCurrentWhitelist('account', 1)
const fakeDispatch = jest.fn()
const fakeGetState = () => ({enabled: true})
const fakeAxios = {
get: jest.fn(() => ({
then(func) {
const fakeResponse = {
data: {
enabled: true,
current_account_whitelist: ['instructure.com', 'canvaslms.com'],
effective_whitelist: ['bridgelms.com']
}
}
func(fakeResponse)
}
}))
}
thunk(fakeDispatch, fakeGetState, {axios: fakeAxios})
expect(fakeDispatch).toHaveBeenCalledWith({
payload: ['bridgelms.com'],
type: 'ADD_DOMAIN_BULK'
})
})
it('dispatches a bulk domain action using the current_account_whitelist when the whitelist is disabled', () => {
const thunk = Actions.getCurrentWhitelist('account', 1)
const fakeDispatch = jest.fn()
const fakeGetState = () => ({enabled: false})
const fakeAxios = {
get: jest.fn(() => ({
then(func) {
const fakeResponse = {
data: {
enabled: false,
current_account_whitelist: ['instructure.com', 'canvaslms.com'],
effective_whitelist: ['bridgelms.com']
}
}
func(fakeResponse)
}
}))
}
thunk(fakeDispatch, fakeGetState, {axios: fakeAxios})
expect(fakeDispatch).toHaveBeenCalledWith({
payload: ['instructure.com', 'canvaslms.com'],
type: 'ADD_DOMAIN_BULK'
})
})
})

View File

@ -16,8 +16,14 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {cspEnabled} from '../reducers'
import {SET_CSP_ENABLED, SET_CSP_ENABLED_OPTIMISTIC} from '../actions'
import {cspEnabled, whitelistedDomains} from '../reducers'
import {
SET_CSP_ENABLED,
SET_CSP_ENABLED_OPTIMISTIC,
ADD_DOMAIN,
ADD_DOMAIN_OPTIMISTIC,
ADD_DOMAIN_BULK
} from '../actions'
describe('cspEnabled', () => {
const testMatrix = [
@ -31,3 +37,37 @@ describe('cspEnabled', () => {
}
)
})
describe('whitelistedDomains', () => {
const testMatrix = [
[{type: ADD_DOMAIN, payload: 'instructure.com'}, [], ['instructure.com']],
[{type: ADD_DOMAIN_OPTIMISTIC, payload: 'instructure.com'}, [], ['instructure.com']],
[
{type: ADD_DOMAIN_BULK, payload: ['instructure.com', 'bridgelms.com']},
['canvaslms.com'],
['canvaslms.com', 'instructure.com', 'bridgelms.com']
]
]
it.each(testMatrix)(
'with %p action and %p payload the whitelistedDomains state becomes %p',
(action, initialState, expectedState) => {
expect(whitelistedDomains(initialState, action)).toEqual(expectedState)
}
)
it('does not allow duplicate domains with ADD_DOMAIN actions', () => {
const action = {type: ADD_DOMAIN, payload: 'instructure.com'}
const initialState = ['instructure.com', 'canvaslms.com']
expect(whitelistedDomains(initialState, action)).toEqual(['instructure.com', 'canvaslms.com'])
})
it('does not allow duplicates domains with ADD_DOMAIN_BULK actions', () => {
const action = {type: ADD_DOMAIN_BULK, payload: ['instructure.com', 'bridgelms.com']}
const initialState = ['instructure.com', 'canvaslms.com']
expect(whitelistedDomains(initialState, action)).toEqual([
'instructure.com',
'canvaslms.com',
'bridgelms.com'
])
})
})

View File

@ -62,3 +62,66 @@ export function getCspEnabled(context, contextId) {
dispatch(setCspEnabledAction(response.data.enabled))
})
}
export const ADD_DOMAIN = 'ADD_DOMAIN'
export const ADD_DOMAIN_BULK = 'ADD_DOMAIN_BULK'
export const ADD_DOMAIN_OPTIMISTIC = 'ADD_DOMAIN_OPTIMISTIC'
export function addDomainAction(domain, opts = {}) {
const type = opts.optimistic ? ADD_DOMAIN_OPTIMISTIC : ADD_DOMAIN
if (typeof domain !== 'string') {
return {
type,
payload: new Error('Can only set to String values'),
error: true
}
}
return {
type,
payload: domain
}
}
export function addDomainBulkAction(domains) {
if (!Array.isArray(domains)) {
return {
type: ADD_DOMAIN_BULK,
payload: new Error('Can only set to an array of strings'),
error: true
}
}
return {
type: ADD_DOMAIN_BULK,
payload: domains
}
}
export function addDomain(context, contextId, domain) {
context = pluralize(context)
return (dispatch, getState, {axios}) => {
dispatch(addDomainAction(domain, {optimistic: true}))
return axios
.post(`/api/v1/${context}/${contextId}/csp_settings/domains`, {
domain
})
.then(() => {
// This isn't really necessary but since the whitelist is unique,
// it doesn't hurt.
dispatch(addDomainAction(domain))
})
}
}
export function getCurrentWhitelist(context, contextId) {
context = pluralize(context)
return (dispatch, getState, {axios}) => {
const {enabled} = getState()
return axios.get(`/api/v1/${context}/${contextId}/csp_settings`).then(response => {
dispatch(
addDomainBulkAction(
response.data[enabled ? 'effective_whitelist' : 'current_account_whitelist']
)
)
})
}
}

View File

@ -24,8 +24,10 @@ import Heading from '@instructure/ui-elements/lib/components/Heading'
import Text from '@instructure/ui-elements/lib/components/Text'
import View from '@instructure/ui-layout/lib/components/View'
import Checkbox from '@instructure/ui-forms/lib/components/Checkbox'
import Grid, {GridCol, GridRow} from '@instructure/ui-layout/lib/components/Grid'
import {getCspEnabled, setCspEnabled} from '../actions'
import {getCspEnabled, setCspEnabled, getCurrentWhitelist} from '../actions'
import {ConnectedWhitelist} from './Whitelist'
export class SecurityPanel extends Component {
static propTypes = {
@ -33,7 +35,8 @@ export class SecurityPanel extends Component {
contextId: string.isRequired,
cspEnabled: bool.isRequired,
getCspEnabled: func.isRequired,
setCspEnabled: func.isRequired
setCspEnabled: func.isRequired,
getCurrentWhitelist: func.isRequired
}
handleCspToggleChange = e => {
@ -42,6 +45,7 @@ export class SecurityPanel extends Component {
componentDidMount() {
this.props.getCspEnabled(this.props.context, this.props.contextId)
this.props.getCurrentWhitelist(this.props.context, this.props.contextId)
}
render() {
@ -60,12 +64,23 @@ export class SecurityPanel extends Component {
)}
</Text>
</View>
<Checkbox
variant="toggle"
label={I18n.t('Enable Content Security Policy')}
onChange={this.handleCspToggleChange}
checked={this.props.cspEnabled}
/>
<Grid>
<GridRow>
<GridCol>
<Checkbox
variant="toggle"
label={I18n.t('Enable Content Security Policy')}
onChange={this.handleCspToggleChange}
checked={this.props.cspEnabled}
/>
</GridCol>
</GridRow>
<GridRow>
<GridCol>
<ConnectedWhitelist context={this.props.context} contextId={this.props.contextId} />
</GridCol>
</GridRow>
</Grid>
</div>
)
}
@ -80,7 +95,8 @@ function mapStateToProps(state, ownProps) {
const mapDispatchToProps = {
getCspEnabled,
setCspEnabled
setCspEnabled,
getCurrentWhitelist
}
export const ConnectedSecurityPanel = connect(

View File

@ -0,0 +1,143 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, {Component} from 'react'
import I18n from 'i18n!security_panel'
import {connect} from 'react-redux'
import {arrayOf, func, oneOf, string} from 'prop-types'
import Heading from '@instructure/ui-elements/lib/components/Heading'
import TextInput from '@instructure/ui-forms/lib/components/TextInput'
import Flex, {FlexItem} from '@instructure/ui-layout/lib/components/Flex'
import Button from '@instructure/ui-buttons/lib/components/Button'
import IconPlus from '@instructure/ui-icons/lib/Solid/IconPlus'
import Table from '@instructure/ui-elements/lib/components/Table'
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
import isValidDomain from 'is-valid-domain'
import {addDomain} from '../actions'
const PROTOCOL_REGEX = /^(?:(ht|f)tp(s?)\:\/\/)?/
export class Whitelist extends Component {
static propTypes = {
addDomain: func.isRequired,
context: oneOf(['course', 'account']).isRequired,
contextId: string.isRequired,
whitelistedDomains: arrayOf(string).isRequired
}
state = {
addDomainInputValue: '',
errors: []
}
validateInput = input => {
const domainOnly = input.replace(PROTOCOL_REGEX, '')
return isValidDomain(domainOnly)
}
handleSubmit = () => {
if (this.validateInput(this.state.addDomainInputValue)) {
this.setState(curState => {
this.props.addDomain(this.props.context, this.props.contextId, curState.addDomainInputValue)
return {
errors: [],
addDomainInputValue: ''
}
})
} else {
this.setState({
errors: [
{
text: I18n.t('Invalid domain'),
type: 'error'
}
]
})
}
}
render() {
return (
<div>
<Heading margin="small 0" level="h4" as="h3" border="bottom">
{I18n.t('Whitelist (%{count}/%{max})', {
count: this.props.whitelistedDomains.length,
max: 100
})}
</Heading>
<form
onSubmit={e => {
e.preventDefault()
this.handleSubmit()
}}
>
<Flex>
<FlexItem grow shrink padding="0 medium 0 0">
<TextInput
label={I18n.t('Add Domain')}
placeholder="http://somedomain.com"
value={this.state.addDomainInputValue}
messages={this.state.errors}
onChange={e => {
this.setState({addDomainInputValue: e.currentTarget.value})
}}
/>
</FlexItem>
<FlexItem align={this.state.errors.length ? 'center' : 'end'}>
<Button type="submit" margin="0 x-small 0 0" icon={IconPlus}>
{I18n.t('Domain')}
</Button>
</FlexItem>
</Flex>
</form>
<Table caption={<ScreenReaderContent>{I18n.t('Whitelisted Domains')}</ScreenReaderContent>}>
<thead>
<tr>
<th scope="col">Domain Name</th>
<th scope="col">
<ScreenReaderContent>Actions</ScreenReaderContent>
</th>
</tr>
</thead>
<tbody>
{this.props.whitelistedDomains.map(domain => (
<tr key={domain}>
<td>{domain}</td>
<td />
</tr>
))}
</tbody>
</Table>
</div>
)
}
}
function mapStateToProps(state, ownProps) {
return {...ownProps, whitelistedDomains: state.whitelistedDomains}
}
const mapDispatchToProps = {
addDomain
}
export const ConnectedWhitelist = connect(
mapStateToProps,
mapDispatchToProps
)(Whitelist)

View File

@ -0,0 +1,83 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {fireEvent} from 'react-testing-library'
import {ConnectedWhitelist} from '../Whitelist'
import {renderWithRedux} from './utils'
describe('ConnectedWhitelist', () => {
it('renders items on the whitelist after they are added', () => {
const {getByLabelText, getByText, container} = renderWithRedux(
<ConnectedWhitelist context="account" contextId="1" />
)
const domainInput = getByLabelText('Add Domain')
fireEvent.input(domainInput, {target: {value: 'instructure.com'}})
const button = container.querySelector('button')
fireEvent.click(button)
const domainCellEntry = getByText('instructure.com')
expect(domainCellEntry).toBeInTheDocument()
})
it('shows an error message when an invalid domain is entered', () => {
const {getByLabelText, getByText, container} = renderWithRedux(
<ConnectedWhitelist context="account" contextId="1" />
)
const domainInput = getByLabelText('Add Domain')
fireEvent.input(domainInput, {target: {value: 'fake'}})
const button = container.querySelector('button')
fireEvent.click(button)
const errorMessage = getByText('Invalid domain')
expect(errorMessage).toBeInTheDocument()
})
it('shows the correct count for the whitelist', () => {
const {getByLabelText, getByText, container} = renderWithRedux(
<ConnectedWhitelist context="account" contextId="1" />
)
const domainInput = getByLabelText('Add Domain')
fireEvent.input(domainInput, {target: {value: 'instructure.com'}})
const button = container.querySelector('button')
fireEvent.click(button)
const countString = getByText('Whitelist (1/100)')
expect(countString).toBeInTheDocument()
})
it('clears the input box after a successful submisssion', () => {
const {getByLabelText, container} = renderWithRedux(
<ConnectedWhitelist context="account" contextId="1" />
)
const domainInput = getByLabelText('Add Domain')
fireEvent.input(domainInput, {target: {value: 'instructure.com'}})
const button = container.querySelector('button')
fireEvent.click(button)
expect(domainInput.getAttribute('value')).toBe('')
})
})

View File

@ -17,7 +17,13 @@
*/
import {combineReducers} from 'redux'
import {SET_CSP_ENABLED, SET_CSP_ENABLED_OPTIMISTIC} from './actions'
import {
SET_CSP_ENABLED,
SET_CSP_ENABLED_OPTIMISTIC,
ADD_DOMAIN,
ADD_DOMAIN_OPTIMISTIC,
ADD_DOMAIN_BULK
} from './actions'
export function cspEnabled(state = false, action) {
switch (action.type) {
@ -29,6 +35,24 @@ export function cspEnabled(state = false, action) {
}
}
export function whitelistedDomains(state = [], action) {
switch (action.type) {
case ADD_DOMAIN:
case ADD_DOMAIN_OPTIMISTIC: {
const domains = new Set(state)
domains.add(action.payload)
return Array.from(domains)
}
case ADD_DOMAIN_BULK: {
const domains = new Set(state.concat(action.payload))
return Array.from(domains)
}
default:
return state
}
}
export default combineReducers({
cspEnabled
cspEnabled,
whitelistedDomains
})

View File

@ -22,7 +22,8 @@ import rootReducer from './reducers'
import axios from 'axios'
export const defaultState = {
cspEnabled: false
cspEnabled: false,
whitelistedDomains: []
}
export function configStore(initialState, options = {}) {

View File

@ -66,6 +66,7 @@
"ic-tabs": "0.1.3",
"immutability-helper": "^2",
"immutable": "^3.8.2",
"is-valid-domain": "^0.0.6",
"jquery": "https://github.com/ryankshaw/jquery.git#a755a3e9c99d5a70d8ea570836f94ae1ba56046d",
"jquery-getscrollbarwidth": "^1.0.0",
"jquery-ui-touch-punch": "^0.2.3",

View File

@ -9835,6 +9835,11 @@ is-utf8@^0.2.0, is-utf8@^0.2.1:
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
is-valid-domain@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/is-valid-domain/-/is-valid-domain-0.0.6.tgz#97f8ec909cffd21f795667029587d73354fd6412"
integrity sha512-XXiNRcLcNKeb0LB3PzB39gJa8QiA+6nnc4NX9zNvFQcaITWU+64hfVqaVppbSd3tSVlJttW6sINkX3xLKPax7A==
is-whitespace-character@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed"