SimonAlling / userscript-proxy

Browser extensions on any device
MIT License
68 stars 3 forks source link

Cannot load scripts in iframe due to Content Security Policy #6

Closed deatondg closed 3 years ago

deatondg commented 3 years ago

My particular use case requires running a user script inside an iframe on my iPad. Unfortunately, it is not a publicly accessible domain, so I cannot share it here.

Attempting to inject my script fails with [Error] Refused to execute a script because its hash, its nonce, or 'unsafe-inline' appears in neither the script-src directive nor the default-src directive of the Content Security Policy. (auth, line 33) once the iframe is included, but I am able to see the script present in the HTML for the iframe.

It seems to me that since this proxy is able to arbitrarily modify the presented HTML, it would be possible to compute the hash of our injected script and add it to the CSP before presenting the page, thus avoiding this issue.

deatondg commented 3 years ago

I am currently working through the source code to try and implement this myself. So far, I have confirmed that simply deleting the Content-Security-Policy header on this domain allows my script to run.

deatondg commented 3 years ago

Placing this block of code

                        # Only support downloaded scripts
                        if not useInline:
                            # There is only work to do if a CSP is present.
                            if "Content-Security-Policy" in flow.response.headers:
                                # Convert the CSP string to a dictionary using comprehension.
                                csp_dict = { f[0]: f[1] for f in [ f.strip().split(' ',1) for f in response.headers["Content-Security-Policy"].split(';') ] }
                                if "script-src" in csp_dict:
                                    # If a script-src is present, we are going to add to it, so add a space
                                    csp_dict["script-src"] += " "
                                else:
                                    if "default-src" in csp_dict:
                                        # If there is no script-src, but there is a default-src, we don't want to break the site, so copy the default-src into the script-src
                                        csp_dict["script-src"] = csp_dict["default-src"] + " "
                                    else:
                                        # Otherwise, we can just set the script-src to be empty.
                                        csp_dict["script-src"] = ""
                                # Add our URL (exists because not useInline) to the script-src
                                csp_dict["script-src"] += script.downloadURL
                                # Convert the dictionary back to a string using format strings and list comprehension
                                csp_string = ' ; '.join([f'{key} {value}' for key, value in csp_dict.items()])

                                # Update the CSP header to our new value
                                response.headers["Content-Security-Policy"] = csp_string

in the if isApplicable(script) block in src/injector.py adds the downloadURL of non-inline scripts into the script-src field of the CSP. I thought about supporting inline scripts as well, but I didn't want to bother figuring out how to compute the required hashes. This works according to my very limited testing, but the code could be much better (this could be run once per request instead of once per script), so I don't feel comfortable making a PR yet.

If there is any demand, I could clean this up and make a PR.

Edit: Replaced the CSP string -> dict conversion to be more idiomatic. It's still fairly unreadable.

SimonAlling commented 3 years ago

Interesting problem, I must say! It's always fun to see Userscript Proxy being used in actual real-world scenarios.

So far, I have confirmed that simply deleting the Content-Security-Policy header on this domain allows my script to run.

Could you perhaps share the CSP header value (appropriately censored if need be) to aid in reproducing the problem?

deatondg commented 3 years ago

It's always fun to try out a new project like this! :) You've made something really awesome.

The domain I am visiting has no CSP, and the domain of the iframe has CSP default-src 'self'; frame-src 'self' ; img-src 'self' ; connect-src 'self'. The snippet I provided modifies the iframe CSP to default-src 'self' ; frame-src 'self' ; img-src 'self' ; connect-src 'self' ; script-src 'self' https://path.to/my/script.js which allows my script to run.

SimonAlling commented 3 years ago

Alright! Great problem description all the way through! I can reproduce the problem locally now. Notably, I don't think it matters whether an iframe is involved or not.

ℹ️ Web server setup ### 📄 `main.go` ```go package main import ( "fmt" "log" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { fmt.Println("Responding") w.Header().Set("Content-Security-Policy", "default-src 'self'; frame-src 'self' ; img-src 'self' ; connect-src 'self'") fmt.Fprintf(w, ` CSP

CSP

`) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe(":80", nil)) } ``` ### 📄 `Dockerfile` ```dockerfile FROM golang:1.13 AS build WORKDIR /app COPY main.go main.go RUN CGO_ENABLED=0 go build -o webserver main.go FROM scratch COPY --from=build /app/webserver /usr/local/bin/webserver CMD ["/usr/local/bin/webserver"] ``` I started the web server like this: ```shell docker build -t csp . && docker run -p 80:80 csp ```

With a CSP-configured web server up and running, I went on and made this change in Userscript Proxy so I could use the included test userscript for testing:

diff --git a/default-userscripts/userscript-proxy-test.user.js b/default-userscripts/userscript-proxy-test.user.js
index 8c33a7d..8a2e294 100644
--- a/default-userscripts/userscript-proxy-test.user.js
+++ b/default-userscripts/userscript-proxy-test.user.js
@@ -1,8 +1,7 @@
 // ==UserScript==
 // @name         Userscript Proxy Test
 // @version      1.0.0
-// @match        *://example.com/*
-// @match        *://www.example.com/*
+// @match        *://*/*
 // @run-at       document-start
 // ==/UserScript==

Then I started Userscript Proxy in host networking mode (so it can connect to the web server):

docker build -t userscript-proxy . && docker run -t --rm --name userscript-proxy --network host userscript-proxy

I could then connect to my web server through Userscript Proxy:

$ curl -v --proxy localhost:8080 localhost
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
[…]
< HTTP/1.1 200 OK
< Content-Security-Policy: default-src 'self'; frame-src 'self' ; img-src 'self' ; connect-src 'self'
[…]
< 
<!DOCTYPE html>
<html><head><title>CSP</title>
</head><body><script data-userscript-proxy-version="1.0.0">
// ==UserScript==
// @name         Userscript Proxy Test
// @version      1.0.0
// @match        *://*/*
// @run-at       document-start
// ==/UserScript==
[…]
</script><h1>CSP</h1>
</body></html>

And sure enough, after setting localhost:8080 as proxy in Firefox, I got a CSP error in the console upon visiting http://192.168.1.212 (connections to localhost aren't proxied by Firefox):

🛑 Content Security Policy: The page’s settings blocked the loading of a resource at inline (“default-src”).

Since I got this error with an inline-injected script, I believe we should be adding 'unsafe-inline' to script-src if useInline is true. But maybe there are security implications we should consider …

deatondg commented 3 years ago

Awesome, I'm glad you were able to reproduce it.

I'm not informed enough to know the full security implications of adding 'unsafe-inline', but I can imagine a scenario in which a website includes an inline script and relies on the CSP to prevent it from running, either by accident or as some security feature to make sure that the CSP isn't being ignored. I have never seen this behavior before though.

Either way, I think the maximally correct solution would be to either include the hash of the inline script or put a nonce on it (examples here) to make sure that only the user's script get injected, and not anything else that might be hanging around on the page. That shouldn't be too much extra work as we need to parse and modify the CSP with either solution.

SimonAlling commented 3 years ago

Great digging! I think a nonce would be the easiest, fastest and cleanest solution.

deatondg commented 3 years ago

I agree, I can't think of any new downsides that change would introduce.

SimonAlling commented 3 years ago

Having tried to implement it for a few hours, I can: The userscript itself might try to load external resources, for example by inserting a <link rel="stylesheet" href="https://example.com/stylesheet.css" />. That doesn't work:

🛑 Content Security Policy: The page’s settings blocked the loading of a resource at https://example.com/stylesheet.css

I'll need to have another look into this to see what can be done.

deatondg commented 3 years ago

Hm... Good point. Perhaps this could be regarded as a feature instead of a bug?

Possible solutions could be to include new header field(s) in the userscript to add things to the CSP (when a CSP is present), to add the same type of thing to the rules file so that it's per-domain, or to have a similar setting as a launch argument to apply uniformly to all sites. Alternatively, there could be a flag to just totally delete the CSP on every site or just on select sites.

SimonAlling commented 3 years ago

Yeah, there are definitely several options to choose from. I have experimented and arrived at a somewhat reasonable solution; will create a PR ASAP.