# 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 collections import copy import urllib.parse from warehouse.config import Environment SELF = "'self'" NONE = "'none'" def _serialize(policy): return "; ".join( [ " ".join([k] + [v2 for v2 in v if v2 is not None]) for k, v in sorted(policy.items()) ] ) def content_security_policy_tween_factory(handler, registry): def content_security_policy_tween(request): resp = handler(request) try: policy = request.find_service(name="csp") except LookupError: policy = collections.defaultdict(list) # Replace CSP headers on /simple/ pages. if request.path.startswith("/simple/"): policy = collections.defaultdict(list) policy["sandbox"] = ["allow-top-navigation"] policy["default-src"] = [NONE] # We don't want to apply our Content Security Policy to the debug # toolbar, that's not part of our application and it doesn't work with # our restrictive CSP. policy = _serialize(policy).format(request=request) if not request.path.startswith("/_debug_toolbar/") and policy: resp.headers["Content-Security-Policy"] = policy return resp return content_security_policy_tween class CSPPolicy(collections.defaultdict): def __init__(self, policy=None): super().__init__(list, policy or {}) def merge(self, policy): for key, attrs in policy.items(): self[key].extend(attrs) # The keyword 'none' must be the only source expression in the # directive value, otherwise it is ignored. If there's more than # one directive set, attempt to remove 'none' if it is present if NONE in self[key] and len(self[key]) > 1: self[key].remove(NONE) def csp_factory(_, request): try: return CSPPolicy(copy.deepcopy(request.registry.settings["csp"])) except KeyError: return CSPPolicy({}) def _connect_src_settings(config) -> list: settings = [ SELF, "https://api.github.com/repos/", "https://api.github.com/search/issues", "https://*.google-analytics.com", "https://*.analytics.google.com", "https://*.googletagmanager.com", "fastly-insights.com", "*.fastly-insights.com", "*.ethicalads.io", "https://api.pwnedpasswords.com", # Scoped deeply to prevent other scripts calling other CDN resources "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/sre/mathmaps/", ] settings.extend( [item for item in [config.registry.settings.get("statuspage.url")] if item] ) if config.registry.settings.get("warehouse.env") == Environment.development: livereload_url = config.registry.settings.get("livereload.url") parsed_url = urllib.parse.urlparse(livereload_url) # Incoming scheme could be http or https. scheme_replacement = "wss" if parsed_url.scheme == "https" else "ws" replaced = parsed_url._replace(scheme=scheme_replacement) # noqa fixed = urllib.parse.urlunparse(replaced) settings.extend( [ f"{fixed}/livereload", ] ) return settings def _script_src_settings(config) -> list: settings = [ SELF, "https://*.googletagmanager.com", "https://www.google-analytics.com", # Remove when disabling UA "https://ssl.google-analytics.com", # Remove when disabling UA "*.fastly-insights.com", "*.ethicalads.io", # Hash for v1.4.0 of ethicalads.min.js "'sha256-U3hKDidudIaxBDEzwGJApJgPEf2mWk6cfMWghrAa6i0='", "https://cdn.jsdelivr.net/npm/mathjax@3.2.2/", # Hash for v3.2.2 of MathJax tex-svg.js "'sha256-1CldwzdEg2k1wTmf7s5RWVd7NMXI/7nxxjJM2C4DqII='", # Hash for MathJax inline config # See warehouse/templates/packaging/detail.html "'sha256-0POaN8stWYQxhzjKS+/eOfbbJ/u4YHO5ZagJvLpMypo='", ] if config.registry.settings.get("warehouse.env") == Environment.development: settings.extend( [ f"{config.registry.settings['livereload.url']}/livereload.js", ] ) return settings def includeme(config): config.register_service_factory(csp_factory, name="csp") # Enable a Content Security Policy config.add_settings( { "csp": { "base-uri": [SELF], "block-all-mixed-content": [], "connect-src": _connect_src_settings(config), "default-src": [NONE], "font-src": [SELF, "fonts.gstatic.com"], "form-action": [SELF, "https://checkout.stripe.com"], "frame-ancestors": [NONE], "frame-src": [NONE], "img-src": [ SELF, config.registry.settings["camo.url"], "https://*.google-analytics.com", "https://*.googletagmanager.com", "*.fastly-insights.com", "*.ethicalads.io", ], "script-src": _script_src_settings(config), "style-src": [ SELF, "fonts.googleapis.com", "*.ethicalads.io", # Hashes for inline styles generated by v1.4.0 of ethicalads.min.js "'sha256-2YHqZokjiizkHi1Zt+6ar0XJ0OeEy/egBnlm+MDMtrM='", "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='", # Hashes for inline styles generated by v3.2.2 of MathJax tex-svg.js "'sha256-JLEjeN9e5dGsz5475WyRaoA4eQOdNPxDIeUhclnJDCE='", "'sha256-mQyxHEuwZJqpxCw3SLmc4YOySNKXunyu2Oiz1r3/wAE='", "'sha256-OCf+kv5Asiwp++8PIevKBYSgnNLNUZvxAp4a7wMLuKA='", "'sha256-h5LOiLhk6wiJrGsG5ItM0KimwzWQH/yAcmoJDJL//bY='", ], "worker-src": ["*.fastly-insights.com"], } } ) config.add_tween("warehouse.csp.content_security_policy_tween_factory")