fix(scan): grade hsts on the final response scheme after redirects (#310)

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.
This commit is contained in:
Tigah
2026-07-22 12:49:40 -07:00
committed by GitHub
parent e2da1bc5d8
commit 5e1c029e09
2 changed files with 67 additions and 1 deletions
+13 -1
View File
@@ -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
+54
View File
@@ -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")