From 06bc9467000b82c79d9437b633577d6357f90217 Mon Sep 17 00:00:00 2001 From: TBX3D <88289044+TBX3D@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:13:01 -0700 Subject: [PATCH] fix(scan): grade hsts on the final response scheme after redirects hsts grading was gated on the scheme of the originally requested url, but the client follows redirects, so an http target that redirects to https skipped the hsts check entirely and dropped a high-severity finding. decide the scheme from the final response request url instead, falling back to the requested url only when no response request is set. --- internal/scan/securityheaders.go | 14 ++++++- internal/scan/securityheaders_test.go | 54 +++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) 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")