Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -35,7 +36,7 @@ Both HTTP and HTTPS are supported.
* [--userscripts-dir DIR, -u DIR](#--userscripts-dir-dir--u-dir)
* [Contribute](#contribute)

<!-- Added by: alling, at: sön 12 apr 2020 19:56:29 CEST -->
<!-- Added by: alling, at: sön 21 mar 2021 20:35:05 CET -->

<!--te-->

Expand Down Expand Up @@ -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 (`<script>...</script>`), never linked (`<script src="..."></script>`).
Expand Down
33 changes: 29 additions & 4 deletions src/injector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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() ]
2 changes: 2 additions & 0 deletions src/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/modules/argparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/modules/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions src/modules/csp.py
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions src/modules/inject.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down