UNPKG

csp_evaluator

Version:

Evaluate Content Security Policies for a wide range of bypasses and weaknesses

608 lines (538 loc) 20.8 kB
/** * @fileoverview Collection of CSP evaluation checks. * @author lwe@google.com (Lukas Weichselbaum) * * @license * Copyright 2016 Google Inc. All rights reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as angular from '../allowlist_bypasses/angular'; import * as flash from '../allowlist_bypasses/flash'; import * as jsonp from '../allowlist_bypasses/jsonp'; import * as csp from '../csp'; import {Csp, Directive, Keyword} from '../csp'; import {Finding, Severity, Type} from '../finding'; import * as utils from '../utils'; /** * A list of CSP directives that can allow XSS vulnerabilities if they fail * validation. */ export const DIRECTIVES_CAUSING_XSS: Directive[] = [ Directive.SCRIPT_SRC, Directive.SCRIPT_SRC_ATTR, Directive.SCRIPT_SRC_ELEM, Directive.OBJECT_SRC, Directive.BASE_URI ]; /** * A list of URL schemes that can allow XSS vulnerabilities when requests to * them are made. */ export const URL_SCHEMES_CAUSING_XSS: string[] = ['data:', 'http:', 'https:']; /** * Checks if passed csp allows inline scripts. * Findings of this check are critical and FP free. * unsafe-inline is ignored in the presence of a nonce or a hash. This check * does not account for this and therefore the effectiveCsp needs to be passed. * * Example policy where this check would trigger: * script-src 'unsafe-inline' * * @param effectiveCsp A parsed csp that only contains values which * are active in a certain version of CSP (e.g. no unsafe-inline if a nonce * is present). */ export function checkScriptUnsafeInline(effectiveCsp: Csp): Finding[] { const violations: Finding[] = []; const directivesToCheck = effectiveCsp.getEffectiveDirectives([ Directive.SCRIPT_SRC, Directive.SCRIPT_SRC_ATTR, Directive.SCRIPT_SRC_ELEM ]); for (const directive of directivesToCheck) { const values = effectiveCsp.directives[directive] || []; if (values.includes(Keyword.UNSAFE_INLINE)) { violations.push(new Finding( Type.SCRIPT_UNSAFE_INLINE, `'unsafe-inline' allows the execution of unsafe in-page scripts ` + 'and event handlers.', Severity.HIGH, directive, Keyword.UNSAFE_INLINE)); } if (values.includes(Keyword.UNSAFE_HASHES)) { violations.push(new Finding( Type.SCRIPT_UNSAFE_HASHES, `'unsafe-hashes', while safer than 'unsafe-inline', allows the execution of unsafe in-page scripts and event handlers as long as their hashes appear in the CSP. Please refactor them to no longer use inline scripts if possible.`, Severity.MEDIUM_MAYBE, directive, Keyword.UNSAFE_HASHES)); } } return violations; } /** * Checks if passed csp allows eval in scripts. * Findings of this check have a medium severity and are FP free. * * Example policy where this check would trigger: * script-src 'unsafe-eval' * * @param parsedCsp Parsed CSP. */ export function checkScriptUnsafeEval(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; const directivesToCheck = parsedCsp.getEffectiveDirectives([ Directive.SCRIPT_SRC, Directive.SCRIPT_SRC_ATTR, Directive.SCRIPT_SRC_ELEM ]); for (const directive of directivesToCheck) { const values = parsedCsp.directives[directive] || []; if (values.includes(Keyword.UNSAFE_EVAL)) { violations.push(new Finding( Type.SCRIPT_UNSAFE_EVAL, `'unsafe-eval' allows the execution of code injected into DOM APIs ` + 'such as eval().', Severity.MEDIUM_MAYBE, directive, Keyword.UNSAFE_EVAL)); } } return violations; } /** * Checks if plain URL schemes (e.g. http:) are allowed in sensitive directives. * Findings of this check have a high severity and are FP free. * * Example policy where this check would trigger: * script-src https: http: data: * * @param parsedCsp Parsed CSP. */ export function checkPlainUrlSchemes(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; const directivesToCheck = parsedCsp.getEffectiveDirectives(DIRECTIVES_CAUSING_XSS); for (const directive of directivesToCheck) { const values = parsedCsp.directives[directive] || []; for (const value of values) { if (URL_SCHEMES_CAUSING_XSS.includes(value)) { violations.push(new Finding( Type.PLAIN_URL_SCHEMES, value + ' URI in ' + directive + ' allows the execution of ' + 'unsafe scripts.', Severity.HIGH, directive, value)); } } } return violations; } /** * Checks if csp contains wildcards in sensitive directives. * Findings of this check have a high severity and are FP free. * * Example policy where this check would trigger: * script-src * * * @param parsedCsp Parsed CSP. */ export function checkWildcards(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; const directivesToCheck = parsedCsp.getEffectiveDirectives(DIRECTIVES_CAUSING_XSS); for (const directive of directivesToCheck) { const values = parsedCsp.directives[directive] || []; for (const value of values) { const url = utils.getSchemeFreeUrl(value); if (url === '*') { violations.push(new Finding( Type.PLAIN_WILDCARD, directive + ` should not allow '*' as source`, Severity.HIGH, directive, value)); continue; } } } return violations; } /** * Checks if object-src is restricted to none either directly or via a * default-src. */ export function checkMissingObjectSrcDirective(parsedCsp: Csp): Finding[] { let objectRestrictions: string[]|undefined = []; if (Directive.OBJECT_SRC in parsedCsp.directives) { objectRestrictions = parsedCsp.directives[Directive.OBJECT_SRC]; } else if (Directive.DEFAULT_SRC in parsedCsp.directives) { objectRestrictions = parsedCsp.directives[Directive.DEFAULT_SRC]; } if (objectRestrictions !== undefined && objectRestrictions.length >= 1) { return []; } return [new Finding( Type.MISSING_DIRECTIVES, `Missing object-src allows the injection of plugins which can execute JavaScript. Can you set it to 'none'?`, Severity.HIGH, Directive.OBJECT_SRC)]; } /** * Checks if script-src is restricted either directly or via a default-src. */ export function checkMissingScriptSrcDirective(parsedCsp: Csp): Finding[] { if (Directive.SCRIPT_SRC in parsedCsp.directives || Directive.DEFAULT_SRC in parsedCsp.directives) { return []; } return [new Finding( Type.MISSING_DIRECTIVES, 'script-src directive is missing.', Severity.HIGH, Directive.SCRIPT_SRC)]; } /** * Checks if the base-uri needs to be restricted and if so, whether it has been * restricted. */ export function checkMissingBaseUriDirective(parsedCsp: Csp): Finding[] { return checkMultipleMissingBaseUriDirective([parsedCsp]); } /** * Checks if the base-uri needs to be restricted and if so, whether it has been * restricted. */ export function checkMultipleMissingBaseUriDirective(parsedCsps: Csp[]): Finding[] { // base-uri can be used to bypass nonce based CSPs and hash based CSPs that // use strict dynamic const needsBaseUri = (csp: Csp) => (csp.policyHasScriptNonces() || (csp.policyHasScriptHashes() && csp.policyHasStrictDynamic())); const hasBaseUri = (csp: Csp) => Directive.BASE_URI in csp.directives; if (parsedCsps.some(needsBaseUri) && !parsedCsps.some(hasBaseUri)) { const description = 'Missing base-uri allows the injection of base tags. ' + 'They can be used to set the base URL for all relative (script) ' + 'URLs to an attacker controlled domain. ' + `Can you set it to 'none' or 'self'?`; return [new Finding( Type.MISSING_DIRECTIVES, description, Severity.HIGH, Directive.BASE_URI)]; } return []; } /** * Checks if all necessary directives for preventing XSS are set. * Findings of this check have a high severity and are FP free. * * Example policy where this check would trigger: * script-src 'none' * * @param parsedCsp Parsed CSP. */ export function checkMissingDirectives(parsedCsp: Csp): Finding[] { return [ ...checkMissingObjectSrcDirective(parsedCsp), ...checkMissingScriptSrcDirective(parsedCsp), ...checkMissingBaseUriDirective(parsedCsp), ]; } /** * Checks if allowlisted origins are bypassable by JSONP/Angular endpoints. * High severity findings of this check are FP free. * * Example policy where this check would trigger: * default-src 'none'; script-src www.google.com * * @param parsedCsp Parsed CSP. */ export function checkScriptAllowlistBypass(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; parsedCsp .getEffectiveDirectives([Directive.SCRIPT_SRC, Directive.SCRIPT_SRC_ELEM]) .forEach(effectiveScriptSrcDirective => { const scriptSrcValues = parsedCsp.directives[effectiveScriptSrcDirective] || []; if (scriptSrcValues.includes(Keyword.NONE)) { return; } for (const value of scriptSrcValues) { if (value === Keyword.SELF) { violations.push(new Finding( Type.SCRIPT_ALLOWLIST_BYPASS, `'self' can be problematic if you host JSONP, AngularJS or user ` + 'uploaded files.', Severity.MEDIUM_MAYBE, effectiveScriptSrcDirective, value)); continue; } // Ignore keywords, nonces and hashes (they start with a single // quote). if (value.startsWith('\'')) { continue; } // Ignore standalone schemes and things that don't look like URLs (no // dot). if (csp.isUrlScheme(value) || value.indexOf('.') === -1) { continue; } const url = '//' + utils.getSchemeFreeUrl(value); const angularBypass = utils.matchWildcardUrls(url, angular.URLS); let jsonpBypass = utils.matchWildcardUrls(url, jsonp.URLS); // Some JSONP bypasses only work in presence of unsafe-eval. if (jsonpBypass) { const evalRequired = jsonp.NEEDS_EVAL.includes(jsonpBypass.hostname); const evalPresent = scriptSrcValues.includes(Keyword.UNSAFE_EVAL); if (evalRequired && !evalPresent) { jsonpBypass = null; } } if (jsonpBypass || angularBypass) { let bypassDomain = ''; let bypassTxt = ''; if (jsonpBypass) { bypassDomain = jsonpBypass.hostname; bypassTxt = ' JSONP endpoints'; } if (angularBypass) { bypassDomain = angularBypass.hostname; bypassTxt += (bypassTxt.trim() === '') ? '' : ' and'; bypassTxt += ' Angular libraries'; } violations.push(new Finding( Type.SCRIPT_ALLOWLIST_BYPASS, bypassDomain + ' is known to host' + bypassTxt + ' which allow to bypass this CSP.', Severity.HIGH, effectiveScriptSrcDirective, value)); } else { violations.push(new Finding( Type.SCRIPT_ALLOWLIST_BYPASS, `No bypass found; make sure that this URL doesn't serve JSONP ` + 'replies or Angular libraries.', Severity.MEDIUM_MAYBE, effectiveScriptSrcDirective, value)); } } }); return violations; } /** * Checks if allowlisted object-src origins are bypassable. * Findings of this check have a high severity and are FP free. * * Example policy where this check would trigger: * default-src 'none'; object-src ajax.googleapis.com * * @param parsedCsp Parsed CSP. */ export function checkFlashObjectAllowlistBypass(parsedCsp: Csp): Finding[] { const violations = []; const effectiveObjectSrcDirective = parsedCsp.getEffectiveDirective(Directive.OBJECT_SRC); const objectSrcValues = parsedCsp.directives[effectiveObjectSrcDirective] || []; // If flash is not allowed in plugin-types, continue. const pluginTypes = parsedCsp.directives[Directive.PLUGIN_TYPES]; if (pluginTypes && !pluginTypes.includes('application/x-shockwave-flash')) { return []; } for (const value of objectSrcValues) { // Nothing to do here if 'none'. if (value === Keyword.NONE) { return []; } const url = '//' + utils.getSchemeFreeUrl(value); const flashBypass = utils.matchWildcardUrls(url, flash.URLS); if (flashBypass) { violations.push(new Finding( Type.OBJECT_ALLOWLIST_BYPASS, flashBypass.hostname + ' is known to host Flash files which allow to bypass this CSP.', Severity.HIGH, effectiveObjectSrcDirective, value)); } else if (effectiveObjectSrcDirective === Directive.OBJECT_SRC) { violations.push(new Finding( Type.OBJECT_ALLOWLIST_BYPASS, `Can you restrict object-src to 'none' only?`, Severity.MEDIUM_MAYBE, effectiveObjectSrcDirective, value)); } } return violations; } /** * Returns whether the given string "looks" like an IP address. This function * only uses basic heuristics and does not accept all valid IPs nor reject all * invalid IPs. */ export function looksLikeIpAddress(maybeIp: string): boolean { if (maybeIp.startsWith('[') && maybeIp.endsWith(']')) { // Looks like an IPv6 address and not a hostname (though it may be some // nonsense like `[foo]`) return true; } if (/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/.test(maybeIp)) { // Looks like an IPv4 address (though it may be something like // `500.600.700.800` return true; } // Won't match IP addresses encoded in other manners (eg octal or // decimal) return false; } /** * Checks if csp contains IP addresses. * Findings of this check are informal only and are FP free. * * Example policy where this check would trigger: * script-src 127.0.0.1 * * @param parsedCsp Parsed CSP. */ export function checkIpSource(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; // Function for checking if directive values contain IP addresses. const checkIp = (directive: string, directiveValues: string[]) => { for (const value of directiveValues) { const host = utils.getHostname(value); if (looksLikeIpAddress(host)) { // Check if localhost. // See 4.8 in https://www.w3.org/TR/CSP2/#match-source-expression if (host === '127.0.0.1') { violations.push(new Finding( Type.IP_SOURCE, directive + ' directive allows localhost as source. ' + 'Please make sure to remove this in production environments.', Severity.INFO, directive, value)); } else { violations.push(new Finding( Type.IP_SOURCE, directive + ' directive has an IP-Address as source: ' + host + ' (will be ignored by browsers!). ', Severity.INFO, directive, value)); } } } }; // Apply check to values of all directives. utils.applyCheckFunktionToDirectives(parsedCsp, checkIp); return violations; } /** * Checks if csp contains directives that are deprecated in CSP3. * Findings of this check are informal only and are FP free. * * Example policy where this check would trigger: * report-uri foo.bar/csp * * @param parsedCsp Parsed CSP. */ export function checkDeprecatedDirective(parsedCsp: Csp): Finding[] { const violations = []; // More details: https://www.chromestatus.com/feature/5769374145183744 if (Directive.REFLECTED_XSS in parsedCsp.directives) { violations.push(new Finding( Type.DEPRECATED_DIRECTIVE, 'reflected-xss is deprecated since CSP2. ' + 'Please, use the X-XSS-Protection header instead.', Severity.INFO, Directive.REFLECTED_XSS)); } // More details: https://www.chromestatus.com/feature/5680800376815616 if (Directive.REFERRER in parsedCsp.directives) { violations.push(new Finding( Type.DEPRECATED_DIRECTIVE, 'referrer is deprecated since CSP2. ' + 'Please, use the Referrer-Policy header instead.', Severity.INFO, Directive.REFERRER)); } // More details: https://github.com/w3c/webappsec-csp/pull/327 if (Directive.DISOWN_OPENER in parsedCsp.directives) { violations.push(new Finding( Type.DEPRECATED_DIRECTIVE, 'disown-opener is deprecated since CSP3. ' + 'Please, use the Cross Origin Opener Policy header instead.', Severity.INFO, Directive.DISOWN_OPENER)); } // More details: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/prefetch-src if (Directive.PREFETCH_SRC in parsedCsp.directives) { violations.push(new Finding( Type.DEPRECATED_DIRECTIVE, 'prefetch-src is deprecated since CSP3. ' + 'Be aware that this feature may cease to work at any time.', Severity.INFO, Directive.PREFETCH_SRC)); } return violations; } /** * Checks if csp nonce is at least 8 characters long. * Findings of this check are of medium severity and are FP free. * * Example policy where this check would trigger: * script-src 'nonce-short' * * @param parsedCsp Parsed CSP. */ export function checkNonceLength(parsedCsp: Csp): Finding[] { const noncePattern = new RegExp('^\'nonce-(.+)\'$'); const violations: Finding[] = []; utils.applyCheckFunktionToDirectives( parsedCsp, (directive, directiveValues) => { for (const value of directiveValues) { const match = value.match(noncePattern); if (!match) { continue; } // Not a nonce. const nonceValue = match[1]; if (nonceValue.length < 8) { violations.push(new Finding( Type.NONCE_LENGTH, 'Nonces should be at least 8 characters long.', Severity.MEDIUM, directive, value)); } if (!csp.isNonce(value, true)) { violations.push(new Finding( Type.NONCE_CHARSET, 'Nonces should only use the base64 charset.', Severity.INFO, directive, value)); } } }); return violations; } /** * Checks if CSP allows sourcing from http:// * Findings of this check are of medium severity and are FP free. * * Example policy where this check would trigger: * report-uri http://foo.bar/csp * * @param parsedCsp Parsed CSP. */ export function checkSrcHttp(parsedCsp: Csp): Finding[] { const violations: Finding[] = []; utils.applyCheckFunktionToDirectives( parsedCsp, (directive, directiveValues) => { for (const value of directiveValues) { const description = directive === Directive.REPORT_URI ? 'Use HTTPS to send violation reports securely.' : 'Allow only resources downloaded over HTTPS.'; if (value.startsWith('http://')) { violations.push(new Finding( Type.SRC_HTTP, description, Severity.MEDIUM, directive, value)); } } }); return violations; } /** * Checks if the policy has configured reporting in a robust manner. */ export function checkHasConfiguredReporting(parsedCsp: Csp): Finding[] { const reportUriValues: string[] = parsedCsp.directives[Directive.REPORT_URI] || []; if (reportUriValues.length > 0) { return []; } const reportToValues: string[] = parsedCsp.directives[Directive.REPORT_TO] || []; if (reportToValues.length > 0) { return [new Finding( Type.REPORT_TO_ONLY, `This CSP policy only provides a reporting destination via the 'report-to' directive. This directive is only supported in Chromium-based browsers so it is recommended to also use a 'report-uri' directive.`, Severity.INFO, Directive.REPORT_TO)]; } return [new Finding( Type.REPORTING_DESTINATION_MISSING, 'This CSP policy does not configure a reporting destination. This makes it difficult to maintain the CSP policy over time and monitor for any breakages.', Severity.INFO, Directive.REPORT_URI)]; }