diff --git a/README.md b/README.md index af71fd5..5ce5ef3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Both HTTP and HTTPS are supported. * [Data usage](#data-usage) * [Userscript compatibility](#userscript-compatibility) * [Options](#options) + * [--bypass-csp ALLOW](#--bypass-csp-allow) * [--inline, -i](#--inline--i) * [--list-injected, -l](#--list-injected--l) * [--no-default-rules](#--no-default-rules) @@ -35,7 +36,7 @@ Both HTTP and HTTPS are supported. * [--userscripts-dir DIR, -u DIR](#--userscripts-dir-dir--u-dir) * [Contribute](#contribute) - + @@ -287,6 +288,15 @@ docker run -t --rm --name userscript-proxy -p 8080:8080 alling/userscript-proxy # flags to `docker run` flags to Userscript Proxy ``` +## `--bypass-csp ALLOW` + +Bypass host site's Content Security Policy (if any) to allow userscripts to run properly. +If `ALLOW` is `script`, the CSP is bypassed only for the userscript itself. +Use `nothing` to never bypass any CSP (meaning userscripts won't work at all on some sites). +Use `everything` to allow everything, which may be necessary if the userscript injects CSS, images etc. +Note that the latter completely disables any CSP from every host site into which a userscript is injected. +Defaults to `script`. + ## `--inline`, `-i` Always inject scripts inline (``), never linked (``). diff --git a/src/injector.py b/src/injector.py index f3fc8cb..8db983c 100644 --- a/src/injector.py +++ b/src/injector.py @@ -9,6 +9,7 @@ import modules.arguments as A import modules.constants as C +import modules.csp as csp import modules.inject as inject import modules.inline as inline import modules.metadata as metadata @@ -139,6 +140,7 @@ def load(self, loader): loader.add_option(sanitize(A.inline), bool, False, A.inline_help) loader.add_option(sanitize(A.no_default_userscripts), bool, False, A.no_default_userscripts_help) loader.add_option(sanitize(A.list_injected), bool, False, A.list_injected_help) + loader.add_option(sanitize(A.bypass_csp), Optional[str], A.bypass_csp_default, A.bypass_csp_help) loader.add_option(sanitize(A.userscripts_dir), Optional[str], A.userscripts_dir_default, A.userscripts_dir_help) loader.add_option(sanitize(A.query_param_to_disable), str, A.query_param_to_disable_default, A.query_param_to_disable_help) @@ -167,7 +169,7 @@ def response(self, flow: http.HTTPFlow): if CONTENT_TYPE in response.headers: if any(map(lambda t: t in response.headers[CONTENT_TYPE], RELEVANT_CONTENT_TYPES)): # Response is a web page; proceed. - insertedScripts: List[str] = [] + injections: List[csp.Injection] = [] soup = BeautifulSoup( response.content, HTML_PARSER, @@ -185,23 +187,31 @@ def response(self, flow: http.HTTPFlow): logError(unsafeSequencesMessage(script)) continue logInfo(f"""Injecting {script.name}{"" if script.version is None else " " + C.VERSION_PREFIX + script.version} into {requestURL} ({"inline" if useInline else "linked"}) ...""") + shouldUseNonce = useInline and option(A.bypass_csp) == A.bypass_csp_script # If not inline, then URL is used for bypassing; if bypass for nothing or everything, then the nonce would have no effect anyway. + nonce = csp.generateNonce() if shouldUseNonce else None result = inject.inject(script, soup, inject.Options( inline = option(A.inline), + nonce = nonce )) if type(result) is BeautifulSoup: soup = result - insertedScripts.append(script.name + ("" if script.version is None else " " + T.stringifyVersion(script.version))) + injections.append(csp.Injection( + userscript = script, + nonce = nonce, + )) else: logError("Injection failed due to the following error:") logError(str(result)) + handleContentSecurityPolicy(response, injections) index_DTD: Optional[int] = indexOfDTD(soup) # Insert information comment: if option(A.list_injected): + namesOfInjectedScripts = [ i.userscript.name + ("" if i.userscript.version is None else " " + T.stringifyVersion(i.userscript.version)) for i in injections ] soup.insert(0 if index_DTD is None else 1+index_DTD, Comment( HTML_INFO_COMMENT_PREFIX + ( - "No matching userscripts for this URL." if insertedScripts == [] - else "These scripts were inserted:\n" + bulletList(insertedScripts) + "No matching userscripts for this URL." if namesOfInjectedScripts == [] + else "These scripts were inserted:\n" + bulletList(namesOfInjectedScripts) ) + "\n" )) # Serialize and encode: @@ -211,4 +221,19 @@ def response(self, flow: http.HTTPFlow): ) +def handleContentSecurityPolicy(response: http.HTTPFlow.response, injections: List[csp.Injection]): + # If there is a CSP header, we may need to modify it for the userscript(s) to work. + ContentSecurityPolicy = "Content-Security-Policy" + if ContentSecurityPolicy in response.headers: + bypassCspValue = option(A.bypass_csp) + if bypassCspValue == A.bypass_csp_script: + logInfo(f"Bypassing host site's Content Security Policy for userscripts only (not any resources injected _by_ userscripts, such as stylesheets and images). Try `{flag(A.bypass_csp)} {A.bypass_csp_everything}` if something does not work properly.") + response.headers[ContentSecurityPolicy] = csp.headerWithScriptsAllowed(response.headers[ContentSecurityPolicy], injections) + elif bypassCspValue == A.bypass_csp_everything: + logInfo(f"Bypassing host site's Content Security Policy altogether due to `{flag(A.bypass_csp)} {A.bypass_csp_everything}`.") + del response.headers[ContentSecurityPolicy] + else: + logWarning(f"Host site has a Content Security Policy. Try the {flag(A.bypass_csp)} flag if userscripts don't work properly.") + + addons = [ UserscriptInjector() ] diff --git a/src/launcher.py b/src/launcher.py index f9804a8..f868cb8 100755 --- a/src/launcher.py +++ b/src/launcher.py @@ -55,6 +55,7 @@ def checkThatUserscriptsDirectoryExistsIfSpecified(directory: str): useTransparent = args.transparent useFiltering = useCustomFiltering or useDefaultRules useIntercept = args.intercept is True + bypassCsp = args.bypass_csp userscriptsDirectory = args.userscripts_dir checkThatUserscriptsDirectoryExistsIfSpecified(userscriptsDirectory) def ruleFilesContent_default(): @@ -101,6 +102,7 @@ def ruleFilesContent_custom(): "--set", f"""{sanitize(A.inline)}={str(args.inline).lower()}""", "--set", f"""{sanitize(A.list_injected)}={str(args.list_injected).lower()}""", "--set", f"""{sanitize(A.no_default_userscripts)}={str(args.no_default_userscripts).lower()}""", + "--set", "" if bypassCsp is None else f"""{sanitize(A.bypass_csp)}={bypassCsp}""", "--set", "" if userscriptsDirectory is None else f"""{sanitize(A.userscripts_dir)}={userscriptsDirectory}""", "--set", f"""{sanitize(A.query_param_to_disable)}={args.query_param_to_disable}""", # Empty string breaks the argument chain: diff --git a/src/modules/argparser.py b/src/modules/argparser.py index 2c34f83..4a7f525 100644 --- a/src/modules/argparser.py +++ b/src/modules/argparser.py @@ -6,6 +6,14 @@ def getArgparser(): argparser = ArgumentParser(description=T.description) + argparser.add_argument( + flag(A.bypass_csp), + type=str, + metavar=A.metavar_allow, + choices=A.bypass_csp_values, + default=A.bypass_csp_default, + help=A.bypass_csp_help, + ) argparser.add_argument( flag(A.intercept), action="store_true", diff --git a/src/modules/arguments.py b/src/modules/arguments.py index 7a3fe6e..022c2ac 100644 --- a/src/modules/arguments.py +++ b/src/modules/arguments.py @@ -4,9 +4,18 @@ metavar_file = "FILE" metavar_dir = "DIR" metavar_param = "PARAM" +metavar_allow = "ALLOW" RULES = "rules" +bypass_csp = "bypass-csp" +bypass_csp_nothing = "nothing" +bypass_csp_script = "script" +bypass_csp_everything = "everything" +bypass_csp_default = bypass_csp_script +bypass_csp_values = { bypass_csp_nothing, bypass_csp_script, bypass_csp_everything } +bypass_csp_help = f"Bypass host site's Content Security Policy to allow userscripts to run properly. If {metavar_allow} is '{bypass_csp_script}', the CSP is bypassed only for the userscript itself. Use '{bypass_csp_everything}' to allow everything, which may be necessary if the userscript injects CSS, images etc. Note that the latter completely disables any CSP from every host site into which a userscript is injected. Default: '{bypass_csp_default}'." + inline = "inline" inline_short = "i" inline_help = "Always insert userscripts inline, never linked" diff --git a/src/modules/csp.py b/src/modules/csp.py new file mode 100644 index 0000000..9a6f41f --- /dev/null +++ b/src/modules/csp.py @@ -0,0 +1,40 @@ +import secrets +from typing import List, NamedTuple, Optional + +from modules.userscript import Userscript +from modules.utilities import isSomething + +# Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + + +class Injection(NamedTuple): + userscript: Userscript + nonce: Optional[str] + + +def headerWithScriptsAllowed(cspHeaderValue: str, injections: List[Injection]) -> str: + # Example CSP header: + # + # Content-Security-Policy: default-src 'self'; frame-src 'self'; img-src https:; connect-src 'self' + # + cspKeyValuePairs = [ directive.strip().split(" ", 1) for directive in cspHeaderValue.split(';') ] + cspDict = { key: value for key, value in cspKeyValuePairs } + if "script-src" not in cspDict: + # Browsers fall back to default-src if there is no script-src. + # Since there was no script-src directive and we are adding one, we include the default-src (if present) in it to avoid breaking the site's effective CSP. + cspDict["script-src"] = cspDict["default-src"] if "default-src" in cspDict else "" + sourcesToAllow = [ source(i) for i in injections ] + cspDict["script-src"] += " " + " ".join(sourcesToAllow) + return '; '.join([ f'{key} {value}' for key, value in cspDict.items() ]) + + +def source(injection: Injection) -> str: + if isSomething(injection.nonce): + return f"'nonce-{injection.nonce}'" + else: + # MDN about host (i.e. download URL) sources: "Unlike other values below, single quotes shouldn't be used." + return injection.userscript.downloadURL + + +def generateNonce(): + return secrets.token_hex() # If no argument is passed, "a reasonable default is used" for the number of bytes. diff --git a/src/modules/inject.py b/src/modules/inject.py index b6de896..69a6f5f 100644 --- a/src/modules/inject.py +++ b/src/modules/inject.py @@ -1,19 +1,22 @@ -from typing import NamedTuple, Union +from typing import NamedTuple, Optional, Union from bs4 import BeautifulSoup, Tag import modules.constants as C import modules.userscript as userscript from modules.userscript import Userscript, document_end, document_idle -from modules.utilities import fromOptional, idem, stripIndentation +from modules.utilities import fromOptional, idem, isSomething, stripIndentation class Options(NamedTuple): inline: bool + nonce: Optional[str] def inject(script: Userscript, soup: BeautifulSoup, options: Options) -> Union[BeautifulSoup, Exception]: useInline = options.inline or script.downloadURL is None tag = soup.new_tag("script") + if isSomething(options.nonce): + tag["nonce"] = options.nonce # Used to bypass CSP for inline-injected userscripts. tag[C.ATTRIBUTE_UP_VERSION] = C.VERSION withLoadListenerIfRunAtIdle = userscript.withEventListener("load") if script.runAt == document_idle else idem withNoframesIfNoframes = userscript.withNoframes if script.noframes else idem