Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

http: Add Secure Headers #4071

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions app/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,7 @@ func getConfig(ctx context.Context) (Config, error) {
MaxReqHeaderBytes: viper.GetInt("max-request-header-bytes"),

DisableHTTPSRedirect: viper.GetBool("disable-https-redirect"),
DisableSecureHeaders: viper.GetBool("disable-secure-headers"),

ListenAddr: viper.GetString("listen"),

Expand Down Expand Up @@ -831,6 +832,7 @@ func init() {
RootCmd.Flags().String("ui-dir", "", "Serve UI assets from a local directory instead of from memory.")

RootCmd.Flags().Bool("disable-https-redirect", def.DisableHTTPSRedirect, "Disable automatic HTTPS redirects.")
RootCmd.Flags().Bool("disable-secure-headers", false, "Disable secure headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Content-Security-Policy).")

migrateCmd.Flags().String("up", "", "Target UP migration to apply.")
migrateCmd.Flags().String("down", "", "Target DOWN migration to roll back to.")
Expand Down
1 change: 1 addition & 0 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type Config struct {
MaxReqHeaderBytes int

DisableHTTPSRedirect bool
DisableSecureHeaders bool

TwilioBaseURL string
SlackBaseURL string
Expand Down
21 changes: 21 additions & 0 deletions app/csp/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package csp

import (
"context"
)

type nonceval struct{}

// WithNonce will add a nonce value to the context.
func WithNonce(ctx context.Context, value string) context.Context {
return context.WithValue(ctx, nonceval{}, value)
}

// NonceValue will return the nonce value from the context.
func NonceValue(ctx context.Context) string {
v := ctx.Value(nonceval{})
if v == nil {
return ""
}
return v.(string)
}
7 changes: 1 addition & 6 deletions app/inithttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ func (app *App) initHTTP(ctx context.Context) error {
})
},

func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, req)
})
},
withSecureHeaders(app.cfg.DisableSecureHeaders, strings.HasPrefix(app.cfg.PublicURL, "https://")),

config.ShortURLMiddleware,

Expand Down
42 changes: 42 additions & 0 deletions app/secureheaders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package app

import (
"net/http"

"github.com/google/uuid"
"github.com/target/goalert/app/csp"
)

func withSecureHeaders(disable, https bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if disable {
return next
}

return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
h := w.Header()
if https {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}

nonce := uuid.NewString()

cspVal := "default-src 'self'; " +
"style-src 'self' 'nonce-" + nonce + "'; " +
"font-src 'self' data:; " +
"object-src 'none'; " +
"media-src 'none'; " +
"img-src 'self' data: https://gravatar.com/avatar/; " +
"script-src 'self' 'nonce-" + nonce + "';"

h.Set("Content-Security-Policy", cspVal)

h.Set("Referrer-Policy", "same-origin")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("X-XSS-Protection", "1; mode=block")

next.ServeHTTP(w, req.WithContext(csp.WithNonce(req.Context(), nonce)))
})
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@dnd-kit/core": "6.1.0",
"@dnd-kit/sortable": "8.0.0",
"@dnd-kit/utilities": "3.2.2",
"@emotion/cache": "11.13.1",
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@material/material-color-utilities": "0.2.7",
Expand Down
6 changes: 4 additions & 2 deletions web/explore.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<html>
<head>
<meta charset="utf-8" />
<meta property="csp-nonce" content="{{ .Nonce }}" />

<meta
name="viewport"
content="user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui"
Expand All @@ -10,8 +12,8 @@
<link rel="stylesheet" href="{{ .Prefix }}/static/explore.css" />
</head>
<body>
<div id="root" />
<script>
<div id="root"></div>
<script nonce="{{.Nonce}}">
pathPrefix = {{.PathPrefix}};
applicationName = {{.ApplicationName}};
</script>
Expand Down
20 changes: 11 additions & 9 deletions web/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"strings"
"time"

"github.com/target/goalert/app/csp"
"github.com/target/goalert/config"
"github.com/target/goalert/util/errutil"
"github.com/target/goalert/version"
Expand Down Expand Up @@ -98,43 +99,44 @@ func NewHandler(uiDir, prefix string) (http.Handler, error) {
mux.HandleFunc("/api/graphql/explore", func(w http.ResponseWriter, req *http.Request) {
cfg := config.FromContext(req.Context())

serveTemplate(uiDir, w, req, exploreTmpl, renderData{
serveTemplate(w, req, exploreTmpl, renderData{
ApplicationName: cfg.ApplicationName(),
Prefix: prefix,
ExtraJS: extraJS,
Nonce: csp.NonceValue(req.Context()),
})
})

mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
cfg := config.FromContext(req.Context())

serveTemplate(uiDir, w, req, indexTmpl, renderData{
serveTemplate(w, req, indexTmpl, renderData{
ApplicationName: cfg.ApplicationName(),
Prefix: prefix,
ExtraJS: extraJS,
Nonce: csp.NonceValue(req.Context()),
})
})

return mux, nil
}

func serveTemplate(uiDir string, w http.ResponseWriter, req *http.Request, tmpl *template.Template, data renderData) {
func serveTemplate(w http.ResponseWriter, req *http.Request, tmpl *template.Template, data renderData) {
var buf bytes.Buffer
err := tmpl.Execute(&buf, data)
if errutil.HTTPError(req.Context(), w, err) {
return
}

nonceFree := make([]byte, buf.Len())
copy(nonceFree, buf.Bytes())
nonceFree = bytes.ReplaceAll(nonceFree, []byte(data.Nonce), nil)
h := sha256.New()
h.Write(buf.Bytes())
h.Write(nonceFree)
etagValue := fmt.Sprintf(`W/"sha256-%s"`, hex.EncodeToString(h.Sum(nil)))
w.Header().Set("ETag", etagValue)

if uiDir == "" {
w.Header().Set("Cache-Control", "private, max-age=60, stale-while-revalidate=600, stale-if-error=259200")
} else {
w.Header().Set("Cache-Control", "no-store")
}
w.Header().Set("Cache-Control", "no-store")

http.ServeContent(w, req, "/", time.Time{}, bytes.NewReader(buf.Bytes()))
}
3 changes: 3 additions & 0 deletions web/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ type renderData struct {

// ExtraJS can be used to load additional javascript.
ExtraJS string

// Nonce is a CSP nonce value.
Nonce string
}

func (r renderData) PathPrefix() string { return strings.TrimSuffix(r.Prefix, "/") }
Expand Down
26 changes: 17 additions & 9 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<meta http-equiv="x-goalert-build-date" content="{{ .BuildStamp }}" />
<meta http-equiv="x-goalert-git-commit" content="{{ .GitCommit }}" />
<meta http-equiv="x-goalert-git-tree-state" content="{{ .GitTreeState }}" />
<meta property="csp-nonce" content="{{ .Nonce }}" />
<link rel="stylesheet" html="{{ .Prefix }}/static/loading.css" />

<title>{{ .ApplicationName }}</title>
Expand Down Expand Up @@ -43,16 +44,23 @@
href="{{ .Prefix }}/static/favicon-192.png"
/>
</head>
<style nonce="{{ .Nonce }}">
#app > .initial-load {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
#app > .initial-load > div {
height: 30vh;
width: 30vw;
}
</style>
<body>
<div id="app">
<div
style="width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;"
>
<div style="height: 30vh; width: 30vw;">
<div class="initial-load">
<div>
<svg
width="100%"
height="100%"
Expand Down Expand Up @@ -137,7 +145,7 @@
</div>
</div>
<div id="graceful-unmount"></div>
<script>
<script nonce="{{.Nonce}}">
pathPrefix = "{{.PathPrefix}}";
applicationName = "{{.ApplicationName}}";
</script>
Expand Down
18 changes: 18 additions & 0 deletions web/live.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ THE SOFTWARE
'}',
].join('')
style.setAttribute('type', 'text/css')

// Add nonce to style tag
const nonce = document.querySelector(
'meta[property="csp-nonce"]',
)?.content
style.setAttribute('nonce', nonce)

head.appendChild(style)
style.styleSheet
? (style.styleSheet.cssText = css)
Expand All @@ -162,6 +169,8 @@ THE SOFTWARE
hasChanged = false
resources[url] = newInfo
for (var header in oldInfo) {
if (header === 'Content-Security-Policy') continue // changes with each new request, due to nonce
if (header === 'Content-Length') continue // can change because of gzip compression, we rely on etag anyway
// do verification based on the header type
var oldValue = oldInfo[header],
newValue = newInfo[header],
Expand All @@ -172,6 +181,15 @@ THE SOFTWARE
// fall through to default
default:
hasChanged = oldValue != newValue
if (hasChanged)
console.log(
'Live.js: ' +
header +
' changed: ' +
oldValue +
' != ' +
newValue,
)
break
}
// if changed, act
Expand Down
28 changes: 21 additions & 7 deletions web/src/app/NewVersionCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,30 @@ import {
UPDATE_NOTIF_DURATION,
} from './config'

/* extractMetaTagValue extracts the value of a meta tag from an HTML string.
* It avoids using DOMParser to avoid issues with CSP.
*/
function extractMetaTagValue(htmlString: string, httpEquiv: string): string {
const lowerHtml = htmlString.toLowerCase()
const startIndex = lowerHtml.indexOf(
`<meta http-equiv="${httpEquiv.toLowerCase()}"`,
)

if (startIndex === -1) return ''

const contentStart = lowerHtml.indexOf('content="', startIndex)
if (contentStart === -1) return ''

const contentEnd = lowerHtml.indexOf('"', contentStart + 9)
if (contentEnd === -1) return ''

return htmlString.slice(contentStart + 9, contentEnd)
}

const fetchCurrentVersion = (): Promise<string> =>
fetch(pathPrefix)
.then((res) => res.text())
.then(
(docStr) =>
new DOMParser()
.parseFromString(docStr, 'text/html')
.querySelector('meta[http-equiv=x-goalert-version]')
?.getAttribute('content') || '',
)
.then((docStr) => extractMetaTagValue(docStr, 'x-goalert-version'))

export default function NewVersionCheck(): JSX.Element {
const [currentVersion, setCurrentVersion] = useState(GOALERT_VERSION)
Expand Down
6 changes: 6 additions & 0 deletions web/src/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export const applicationName = global.applicationName || 'GoAlert'
export const GOALERT_VERSION = global.GOALERT_VERSION || 'dev'

export const isCypress = Boolean(global.Cypress)

// read nonce from csp-nonce meta tag
export const nonce =
document
.querySelector('meta[property="csp-nonce"]')
?.getAttribute('content') || ''
Loading
Loading