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
84 changes: 80 additions & 4 deletions runtime/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"html"
"io/fs"
"net"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -499,6 +500,9 @@ func CSRFInjectHTML(response http.ResponseWriter, request *http.Request, payload
if !formStartTagHasPostMethod(tag) {
continue
}
if !formStartTagHasSameOriginAction(request, tag) {
continue
}
if token == "" {
generated, err := source.Token(response, request)
if err != nil {
Expand Down Expand Up @@ -543,13 +547,85 @@ func formStartTagRanges(payload []byte) [][2]int {
}

func formStartTagHasPostMethod(tag []byte) bool {
value, ok := formStartTagAttrValue(tag, "method")
return ok && strings.EqualFold(html.UnescapeString(string(value)), http.MethodPost)
}

func formStartTagHasSameOriginAction(request *http.Request, tag []byte) bool {
value, ok := formStartTagAttrValue(tag, "action")
if !ok {
return true
}
action := strings.TrimSpace(html.UnescapeString(string(value)))
if action == "" {
return true
}
if formActionHasBrowserNetworkPath(action) {
return false
}
if request == nil {
return false
}
scheme, host := requestOrigin(request)
if scheme == "" || host == "" {
return false
}
actionURL, err := url.Parse(action)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use browser URL parsing for form actions

When an HTML file reaches this runtime path with an action like ///evil.example/collect or /\evil.example/collect, Go's net/url resolves it as a same-host path, but browsers submit those forms as network-path URLs to evil.example (the repo's URL safety code already treats /\ as protocol-relative). In that scenario this check returns same-origin and injects the CSRF token into an off-origin POST form, reintroducing the token leak this commit is trying to prevent.

Useful? React with 👍 / 👎.

if err != nil {
return false
}
resolved := (&url.URL{Scheme: scheme, Host: host, Path: "/"}).ResolveReference(actionURL)
return strings.EqualFold(resolved.Scheme, scheme) &&
strings.EqualFold(canonicalOriginHost(resolved.Scheme, resolved.Host), canonicalOriginHost(scheme, host))
}

func requestOrigin(request *http.Request) (string, string) {
scheme := ""
if requestIsHTTPS(request) {
scheme = "https"
} else if request.URL != nil {
scheme = request.URL.Scheme
}
if scheme == "" {
scheme = "http"
}
host := request.Host
if host == "" && request.URL != nil {
host = request.URL.Host
}
return scheme, host
}

func formActionHasBrowserNetworkPath(action string) bool {
if len(action) < 2 {
return false
}
first := action[0]
second := action[1]
return (first == '/' || first == '\\') && (second == '/' || second == '\\')
}

func canonicalOriginHost(scheme string, host string) string {
host = strings.ToLower(strings.TrimSpace(host))
name, port, err := net.SplitHostPort(host)
if err == nil {
defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
if defaultPort {
return strings.ToLower(strings.Trim(name, "[]"))
}
return strings.ToLower(strings.Trim(name, "[]") + ":" + port)
}
return strings.Trim(host, "[]")
}

func formStartTagAttrValue(tag []byte, attrName string) ([]byte, bool) {
cursor := len("<form")
for cursor < len(tag) {
for cursor < len(tag) && isHTMLSpace(tag[cursor]) {
cursor++
}
if cursor >= len(tag) || tag[cursor] == '>' || tag[cursor] == '/' {
return false
return nil, false
}
nameStart := cursor
for cursor < len(tag) && !isHTMLSpace(tag[cursor]) && tag[cursor] != '=' && tag[cursor] != '/' && tag[cursor] != '>' {
Expand All @@ -568,11 +644,11 @@ func formStartTagHasPostMethod(tag []byte) bool {
}
value, next := htmlAttrValue(tag, cursor)
cursor = next
if bytes.EqualFold(name, []byte("method")) && strings.EqualFold(string(value), http.MethodPost) {
return true
if bytes.EqualFold(name, []byte(attrName)) {
return value, true
}
}
return false
return nil, false
}

func htmlTagEnd(payload []byte, cursor int) int {
Expand Down
96 changes: 96 additions & 0 deletions runtime/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,102 @@ func TestHandlerInjectsCSRFHiddenInputsIntoPOSTForms(t *testing.T) {
}
}

func TestCSRFInjectHTMLSkipsOffOriginPOSTFormActions(t *testing.T) {
csrf := &fakeCSRFTokenSource{field: "_csrf", token: "signed-token"}
payload := []byte(`<main>` +
`<form method="post" action="https://payments.example/checkout"></form>` +
`<form method="post" action="//evil.example/collect"></form>` +
`<form method="post" action="///evil.example/collect"></form>` +
`<form method="post" action="/\evil.example/collect"></form>` +
`<form method="post" action="\/evil.example/collect"></form>` +
`<form method="post" action="\\evil.example/collect"></form>` +
`<form method="post" action="http://example.com/signup"></form>` +
`<form method="post" action="/local"></form>` +
`</main>`)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://example.com/page", nil)

updated, ok := CSRFInjectHTML(recorder, request, payload, csrf)

if !ok {
t.Fatal("expected csrf injection to succeed")
}
body := string(updated)
for _, external := range []string{
`<form method="post" action="https://payments.example/checkout"></form>`,
`<form method="post" action="//evil.example/collect"></form>`,
`<form method="post" action="///evil.example/collect"></form>`,
`<form method="post" action="/\evil.example/collect"></form>`,
`<form method="post" action="\/evil.example/collect"></form>`,
`<form method="post" action="\\evil.example/collect"></form>`,
} {
if !strings.Contains(body, external) {
t.Fatalf("expected off-origin form to remain without csrf input: %s", body)
}
}
for _, local := range []string{
`<form method="post" action="http://example.com/signup"><input type="hidden" name="_csrf" value="signed-token">`,
`<form method="post" action="/local"><input type="hidden" name="_csrf" value="signed-token">`,
} {
if !strings.Contains(body, local) {
t.Fatalf("expected same-origin form to receive csrf input %q in %s", local, body)
}
}
if count := strings.Count(body, `name="_csrf"`); count != 2 {
t.Fatalf("expected csrf input only for same-origin forms, got %d: %s", count, body)
}
if csrf.calls != 1 {
t.Fatalf("expected one token generation call, got %d", csrf.calls)
}
if cache := recorder.Header().Get("Cache-Control"); cache != "no-store" {
t.Fatalf("expected no-store for csrf-personalized HTML, got %q", cache)
}
}

func TestCSRFInjectHTMLUsesForwardedHTTPSForSameOriginActions(t *testing.T) {
csrf := &fakeCSRFTokenSource{field: "_csrf", token: "signed-token"}
payload := []byte(`<main><form method="post" action="https://example.com/signup"></form></main>`)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://example.com/page", nil)
request.Header.Set("X-Forwarded-Proto", "https")

updated, ok := CSRFInjectHTML(recorder, request, payload, csrf)

if !ok {
t.Fatal("expected csrf injection to succeed")
}
body := string(updated)
expected := `<form method="post" action="https://example.com/signup"><input type="hidden" name="_csrf" value="signed-token">`
if !strings.Contains(body, expected) {
t.Fatalf("expected forwarded HTTPS same-origin form to receive csrf input, got %s", body)
}
if csrf.calls != 1 {
t.Fatalf("expected one token generation call, got %d", csrf.calls)
}
}

func TestCSRFInjectHTMLDoesNotGenerateTokenForOnlyOffOriginPOSTForms(t *testing.T) {
csrf := &fakeCSRFTokenSource{field: "_csrf", token: "signed-token"}
payload := []byte(`<main><form method="post" action="https://payments.example/checkout"></form></main>`)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://example.com/page", nil)

updated, ok := CSRFInjectHTML(recorder, request, payload, csrf)

if !ok {
t.Fatal("expected csrf injection to succeed")
}
if string(updated) != string(payload) {
t.Fatalf("expected off-origin form payload to remain unchanged, got %s", updated)
}
if csrf.calls != 0 {
t.Fatalf("expected no token generation for off-origin form, got %d", csrf.calls)
}
if cache := recorder.Header().Get("Cache-Control"); cache != "" {
t.Fatalf("expected no cache-control mutation without injected token, got %q", cache)
}
}

func TestHandlerReturnsNoStoreErrorWhenCSRFTokenGenerationFails(t *testing.T) {
handler := Handler{
Root: fstest.MapFS{
Expand Down