diff --git a/internal/scan/securityheaders.go b/internal/scan/securityheaders.go index 2b12e12f..d6c13a49 100644 --- a/internal/scan/securityheaders.go +++ b/internal/scan/securityheaders.go @@ -78,7 +78,7 @@ func SecurityHeaders(url string, timeout time.Duration, logdir string) (Security // header-only scan: drain on close so the conn is returned to the pool. defer httpx.DrainClose(resp) - results := gradeSecurityHeaders(resp.Header, strings.HasPrefix(url, "https://")) + results := gradeSecurityHeaders(resp.Header, responseIsHTTPS(resp, url)) for _, r := range results { line := r.Header + " " + r.Note @@ -96,6 +96,18 @@ func SecurityHeaders(url string, timeout time.Duration, logdir string) (Security return results, nil } +// responseIsHTTPS reports whether the response was actually served over +// https. the client follows redirects, so a request that started as +// http:// can end up served over https:// (or vice versa); the final +// scheme lives on resp.Request.URL, not the originally requested url. +// falls back to the requested url's scheme if that's unavailable. +func responseIsHTTPS(resp *http.Response, requestedURL string) bool { + if resp != nil && resp.Request != nil && resp.Request.URL != nil { + return resp.Request.URL.Scheme == "https" + } + return strings.HasPrefix(requestedURL, "https://") +} + func gradeSecurityHeaders(header http.Header, https bool) SecurityHeaderResults { var results SecurityHeaderResults diff --git a/internal/scan/securityheaders_test.go b/internal/scan/securityheaders_test.go index 90728b9b..3a2478d7 100644 --- a/internal/scan/securityheaders_test.go +++ b/internal/scan/securityheaders_test.go @@ -15,6 +15,7 @@ package scan import ( "net/http" "net/http/httptest" + "net/url" "testing" "time" ) @@ -151,6 +152,59 @@ func TestGradeSecurityHeaders_Disclosure(t *testing.T) { } } +func TestResponseIsHTTPS_UsesFinalRequestScheme(t *testing.T) { + httpURL, err := url.Parse("http://example.com") + if err != nil { + t.Fatal(err) + } + httpsURL, err := url.Parse("https://example.com") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + resp *http.Response + requestedURL string + want bool + }{ + { + // the requested url was http, but the client followed a redirect + // to https; hsts must be graded against the actual scheme. + name: "redirected from http to https", + resp: &http.Response{Request: &http.Request{URL: httpsURL}}, + requestedURL: "http://example.com", + want: true, + }, + { + name: "redirected from https to http", + resp: &http.Response{Request: &http.Request{URL: httpURL}}, + requestedURL: "https://example.com", + want: false, + }, + { + name: "no redirect, stays http", + resp: &http.Response{Request: &http.Request{URL: httpURL}}, + requestedURL: "http://example.com", + want: false, + }, + { + name: "no request on response falls back to requested url", + resp: &http.Response{}, + requestedURL: "https://example.com", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := responseIsHTTPS(tt.resp, tt.requestedURL); got != tt.want { + t.Errorf("responseIsHTTPS() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSecurityHeaders_LiveResponse(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Frame-Options", "SAMEORIGIN")