many: change CSP to use hashes instead of nonces
Change our content security policy (CSP) for scripts.
Instead of using a nonce, which lends itself poorly to caching, use
hashes. See https://csp.withgoogle.com/docs/faq.html, search for "CSP
hashes".
To make hashes work, the hash of every inline script must appear in
our Content-Security-Policy header.
Also, not all browsers support hashing with scripts loaded from files,
so we must dynamically load the files by using an inline script that
builds a script tag with a src attribute. (We need to do this anyway
for the Google Tag Manager script.) See the link above for a
description of the technique. It works because the CSP header mentions
'strict-dynamic', which trusts everything loaded from a trusted
script.
Ideally, we would both generate all these hashes automatically,
and check that they are all correct. This CL doesn't do that.
A followup CL will.
List of changes:
- Replace script tags with scr attributes with inline scripts that
load from the files.
- In internal/middleware/secureheaders.go, add the list of script
hashes to the CSP header.
- Remove all references to nonces.
Updates b/159711607.
Change-Id: Ia9b78ecd85e24619e758f2580a370778708b9e71
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/239897
Reviewed-by: Roberto Clapis <robclap8@gmail.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/content/static/html/base.tmpl b/content/static/html/base.tmpl
index 92195d7..b3edc9a 100644
--- a/content/static/html/base.tmpl
+++ b/content/static/html/base.tmpl
@@ -179,14 +179,38 @@
</div>
</div>
</footer>
+
+<script>
+ function loadScript(src) {
+ let s = document.createElement("script");
+ s.src = src;
+ document.head.appendChild(s);
+ }
+
+ loadScript("/static/js/base.min.js");
+</script>
+
{{block "post_content" .}}{{end}}
+
{{if not .DevMode}}
- <script nonce="{{.Nonce}}" async src="https://www.googletagmanager.com/gtm.js?id={{.GoogleTagManagerContainerID}}"></script>
- <noscript><iframe nonce="{{.Nonce}}" src="https://www.googletagmanager.com/ns.html?id={{.GoogleTagManagerContainerID}}"
- height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
+<script async>
+ var gtmURL = "https://www.googletagmanager.com/gtm.js?id=GTM-W8MVQXG";
+ loadScript(gtmURL);
+
+ f = document.createElement("iframe")
+ f.src = gtmURL;
+ f.height = "0";
+ f.width = "0";
+ f.style = "display:none;visibility:hidden"
+ s = document.createElement("noscript");
+ s.appendChild(f);
+ document.head.appendChild(s);
+</script>
{{end}}
-<script nonce="{{.Nonce}}" src="/static/js/base.min.js?version={{.AppVersionLabel}}"></script>
+
{{if (.Experiments.IsActive "autocomplete")}}
- <script nonce="{{.Nonce}}" src="/third_party/autoComplete.js/autoComplete.min.js?version={{.AppVersionLabel}}"></script>
- <script nonce="{{.Nonce}}" src="/static/js/completion.min.js?version={{.AppVersionLabel}}"></script>
+<script>
+ loadScript("/third_party/autoComplete.js/autoComplete.min.js");
+ loadScript("/static/js/completion.min.js");
+</script>
{{end}}
diff --git a/content/static/html/pages/details.tmpl b/content/static/html/pages/details.tmpl
index 50c48e2..096ce0f 100644
--- a/content/static/html/pages/details.tmpl
+++ b/content/static/html/pages/details.tmpl
@@ -120,7 +120,12 @@
{{end}}
{{define "post_content"}}
-<script nonce="{{.Nonce}}">
+
+<!-- inputEl.blur() below prevents a jump to the focused element in some browsers.
+ Do not add comments to the script below: they are stripped by html/template
+ (https://golang.org/issue/28628), which messes up the CSP hash.
+-->
+<script>
const navEl = document.querySelector('.js-modulesNav');
const selectedEl = navEl.querySelector(`[aria-selected='true']`);
if (selectedEl.offsetLeft + selectedEl.offsetWidth > navEl.offsetWidth) {
@@ -134,7 +139,7 @@
const inputEl = document.querySelector('.js-detailsHeaderPathInput');
inputEl.select();
document.execCommand('copy');
- inputEl.blur(); // prevents jump to focused element in some browsers
+ inputEl.blur();
});
}
</script>
diff --git a/content/static/html/pages/notfound.tmpl b/content/static/html/pages/notfound.tmpl
index 3c71936..b51399b 100644
--- a/content/static/html/pages/notfound.tmpl
+++ b/content/static/html/pages/notfound.tmpl
@@ -15,7 +15,12 @@
</div>
</div>
-<script nonce="{{.Nonce}}">
+<!-- TODO: update middleware.AcceptMethods so that the GET at the end of the
+ script can be a POST instead.
+ Do not add comments to the script below: they are stripped by html/template
+ (https://golang.org/issue/28628), which messes up the CSP hash.
+-->
+<script>
const fetchButton = document.querySelector('.js-notFoundButton');
if (fetchButton) {
fetchButton.addEventListener('click', e => {
@@ -45,7 +50,6 @@
};
document.querySelector('.js-notFoundMessage').innerHTML = "Fetching... Feel free to navigate away and check back later, we'll keep working on it!";
btn.innerHTML = "Fetching...";
- // TODO: update middleware.AcceptMethods so that this is POST instead of a GET request.
httpRequest.open('GET', "/fetch" + window.location.pathname);
httpRequest.send();
}
diff --git a/content/static/html/pages/pkg_doc.tmpl b/content/static/html/pages/pkg_doc.tmpl
index 18e5794..81aa73a 100644
--- a/content/static/html/pages/pkg_doc.tmpl
+++ b/content/static/html/pages/pkg_doc.tmpl
@@ -49,5 +49,7 @@
{{end}}
{{define "details_post_content"}}
- <script nonce="{{.Nonce}}" src="/static/js/jump.min.js?version={{.AppVersionLabel}}"></script>
+<script>
+loadScript("/static/js/jump.min.js");
+</script>
{{end}}
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 4db0c78..a72ef01 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -186,7 +186,6 @@
type basePage struct {
HTMLTitle string
Query string
- Nonce string
Experiments *experiment.Set
GodocURL string
DevMode bool
@@ -217,7 +216,6 @@
return basePage{
HTMLTitle: title,
Query: searchQuery(r),
- Nonce: middleware.NoncePlaceholder,
Experiments: experiment.FromContext(r.Context()),
GodocURL: middleware.GodocURLPlaceholder,
DevMode: s.devMode,
@@ -225,11 +223,6 @@
}
}
-// GoogleTagManagerContainerID returns the container ID from GoogleTagManager.
-func (b basePage) GoogleTagManagerContainerID() string {
- return "GTM-W8MVQXG"
-}
-
// errorPage contains fields for rendering a HTTP error page.
type errorPage struct {
basePage
@@ -320,9 +313,6 @@
if page == nil {
page = &errorPage{}
}
- if page.Nonce == "" {
- page.Nonce = middleware.NoncePlaceholder
- }
if page.messageTemplate == "" {
page.messageTemplate = `<h3 class="Error-message">{{.}}</h3>`
}
diff --git a/internal/middleware/godoc.go b/internal/middleware/godoc.go
index 8920442..e93b828 100644
--- a/internal/middleware/godoc.go
+++ b/internal/middleware/godoc.go
@@ -102,3 +102,18 @@
result.RawQuery = q.Encode()
return result.String()
}
+
+// capturingResponseWriter is an http.ResponseWriter that captures
+// the body for later processing.
+type capturingResponseWriter struct {
+ http.ResponseWriter
+ buf bytes.Buffer
+}
+
+func (c *capturingResponseWriter) Write(b []byte) (int, error) {
+ return c.buf.Write(b)
+}
+
+func (c *capturingResponseWriter) bytes() []byte {
+ return c.buf.Bytes()
+}
diff --git a/internal/middleware/nonce.go b/internal/middleware/nonce.go
deleted file mode 100644
index d12b48a..0000000
--- a/internal/middleware/nonce.go
+++ /dev/null
@@ -1,27 +0,0 @@
-// Copyright 2019 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Package middleware implements a simple middleware pattern for http handlers,
-// along with implementations for some common middlewares.
-package middleware
-
-import (
- "crypto/rand"
- "encoding/base64"
- "fmt"
- "io"
-)
-
-const (
- nonceCtxKey = "CSPNonce"
- nonceLen = 20
-)
-
-func generateNonce() (string, error) {
- nonceBytes := make([]byte, nonceLen)
- if _, err := io.ReadAtLeast(rand.Reader, nonceBytes, nonceLen); err != nil {
- return "", fmt.Errorf("io.ReadAtLeast(rand.Reader, nonceBytes, %d): %v", nonceLen, err)
- }
- return base64.StdEncoding.EncodeToString(nonceBytes), nil
-}
diff --git a/internal/middleware/secureheaders.go b/internal/middleware/secureheaders.go
index 959c36b..ee36bc2 100644
--- a/internal/middleware/secureheaders.go
+++ b/internal/middleware/secureheaders.go
@@ -5,27 +5,29 @@
package middleware
import (
- "bytes"
"fmt"
"net/http"
"strings"
-
- "golang.org/x/pkgsite/internal/log"
)
-// NoncePlaceholder should be used as the value for nonces in rendered content.
-// It is substituted for the actual nonce value by the SecureHeaders middleware.
-const NoncePlaceholder = "$$GODISCOVERYNONCE$$"
+var scriptHashes = []string{
+ // From content/static/html/pages/base.tmpl
+ "'sha256-d6W7MwuGWbguTHRzQhf5QN1jXmNo9Ao218saZkWLWZI='",
+ "'sha256-CCu0fuIQFBHSCEpfR6ZRzzcczJIS/VGMGrez8LR49WY='",
+ "'sha256-qPGTOKPn+niRiNKQIEX0Ktwuj+D+iPQWIxnlhPicw58='",
+ // From content/static/html/pages/notfound.tmpl
+ "'sha256-h5L4TV5GzTaBQYCnA8tDw9+9/AIdK9dwgkwlqFjVqEI='",
+ // From content/static/html/pages/details.tmpl
+ "'sha256-s16e7aT7Gsajq5UH1DbaEFEnNx2VjvS5Xixcxwm4+F8='",
+ // From content/static/html/pages/pkg_doc.tmpl
+ "'sha256-AvMTqQ+22BA0Nsht+ajju4EQseFQsoG1RxW3Nh6M+wc='",
+}
// SecureHeaders adds a content-security-policy and other security-related
// headers to all responses.
func SecureHeaders() Middleware {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- nonce, err := generateNonce()
- if err != nil {
- log.Infof(r.Context(), "generateNonce(): %v", err)
- }
csp := []string{
// Disallow plugin content: pkg.go.dev does not use it.
"object-src 'none'",
@@ -33,9 +35,8 @@
// locations of scripts loaded from relative URLs. The site doesn’t have
// a <base> tag anyway.
"base-uri 'none'",
-
- fmt.Sprintf("script-src 'nonce-%s' 'unsafe-inline' 'strict-dynamic' https: http:",
- nonce),
+ fmt.Sprintf("script-src 'unsafe-inline' 'strict-dynamic' https: http: %s",
+ strings.Join(scriptHashes, " ")),
}
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; "))
// Don't allow frame embedding.
@@ -43,27 +44,7 @@
// Prevent MIME sniffing.
w.Header().Set("X-Content-Type-Options", "nosniff")
- crw := &capturingResponseWriter{ResponseWriter: w}
- h.ServeHTTP(crw, r)
- body := bytes.ReplaceAll(crw.bytes(), []byte(NoncePlaceholder), []byte(nonce))
- if _, err := w.Write(body); err != nil {
- log.Errorf(r.Context(), "SecureHeaders, writing: %v", err)
- }
+ h.ServeHTTP(w, r)
})
}
}
-
-// capturingResponseWriter is an http.ResponseWriter that captures
-// the body for later processing.
-type capturingResponseWriter struct {
- http.ResponseWriter
- buf bytes.Buffer
-}
-
-func (c *capturingResponseWriter) Write(b []byte) (int, error) {
- return c.buf.Write(b)
-}
-
-func (c *capturingResponseWriter) bytes() []byte {
- return c.buf.Bytes()
-}
diff --git a/internal/middleware/secureheaders_test.go b/internal/middleware/secureheaders_test.go
index d5bc075..ebb0566 100644
--- a/internal/middleware/secureheaders_test.go
+++ b/internal/middleware/secureheaders_test.go
@@ -5,36 +5,13 @@
package middleware
import (
- "fmt"
- "io/ioutil"
"net/http"
"net/http/httptest"
- "regexp"
"testing"
)
func TestSecureHeaders(t *testing.T) {
- const origBody = `
- <link foo>
- <script nonce="$$GODISCOVERYNONCE$$" async src="bar"></script>
- blah blah blah
- <script nonce="$$GODISCOVERYNONCE$$">js</script>
- bloo bloo bloo
- <iframe nonce="$$GODISCOVERYNONCE$$" src="baz"></iframe>
-`
-
- const wantBodyFmt = `
- <link foo>
- <script nonce="%[1]s" async src="bar"></script>
- blah blah blah
- <script nonce="%[1]s">js</script>
- bloo bloo bloo
- <iframe nonce="%[1]s" src="baz"></iframe>
-`
-
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprint(w, origBody)
- })
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
mw := SecureHeaders()
ts := httptest.NewServer(mw(handler))
defer ts.Close()
@@ -43,7 +20,7 @@
t.Errorf("GET returned error %v", err)
}
defer resp.Body.Close()
- // Simply test that the expected headers are set.
+ // Test that the expected headers are set.
expectedHeaders := []string{
"content-security-policy",
"x-frame-options",
@@ -54,21 +31,4 @@
t.Errorf("GET returned empty %s", header)
}
}
-
- // Check that the nonce was substituted correctly.
- // We need to extract it from the header.
- nonceRE := regexp.MustCompile(`'nonce-([^']+)'`)
- matches := nonceRE.FindStringSubmatch(resp.Header.Get("content-security-policy"))
- if matches == nil {
- t.Fatal("cannot extract nonce")
- }
- body, err := ioutil.ReadAll(resp.Body)
- if err != nil {
- t.Fatal(err)
- }
- gotBody := string(body)
- wantBody := fmt.Sprintf(wantBodyFmt, matches[1])
- if gotBody != wantBody {
- t.Errorf("got body %s\nwant body %s", gotBody, wantBody)
- }
}