Skip to content

Commit 55fa8c2

Browse files
committed
RF: hsts
1 parent 579e631 commit 55fa8c2

File tree

7 files changed

+239
-60
lines changed

7 files changed

+239
-60
lines changed

checks/checks.go

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type Checks struct {
1414
Rank *Rank
1515
SocialTags *SocialTags
1616
Tls *Tls
17+
Hsts *Hsts
1718
}
1819

1920
func NewChecks() *Checks {
@@ -27,5 +28,6 @@ func NewChecks() *Checks {
2728
Rank: NewRank(client),
2829
SocialTags: NewSocialTags(client),
2930
Tls: NewTls(client),
31+
Hsts: NewHsts(client),
3032
}
3133
}

checks/hsts.go

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package checks
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"strconv"
7+
"strings"
8+
"unicode"
9+
)
10+
11+
const (
12+
StrictTransportSecurity = "Strict-Transport-Security"
13+
includeSubDomains = "includeSubDomains"
14+
preload = "preload"
15+
NilHeadersError = "Site does not serve any HSTS headers."
16+
MaxAgeError = "HSTS max-age is less than 10886400."
17+
SubdomainsError = "HSTS header does not include all subdomains."
18+
PreloadError = "HSTS header does not contain the preload directive."
19+
HstsSuccess = "Site is compatible with the HSTS preload list!"
20+
)
21+
22+
type HSTSResponse struct {
23+
Message string `json:"message"`
24+
Compatible bool `json:"compatible"`
25+
HSTSHeader string `json:"hstsHeader"`
26+
}
27+
28+
type Hsts struct {
29+
client *http.Client
30+
}
31+
32+
func NewHsts(client *http.Client) *Hsts {
33+
return &Hsts{client: client}
34+
}
35+
36+
func (h *Hsts) Validate(ctx context.Context, url string) (*HSTSResponse, error) {
37+
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
resp, err := h.client.Do(req)
43+
if err != nil {
44+
return nil, err
45+
}
46+
defer resp.Body.Close()
47+
48+
hstsHeader := resp.Header.Get(StrictTransportSecurity)
49+
if hstsHeader == "" {
50+
return &HSTSResponse{
51+
Message: NilHeadersError,
52+
}, nil
53+
}
54+
55+
maxAge := extractMaxAgeFromHeader(hstsHeader)
56+
if maxAge == "" {
57+
return &HSTSResponse{Message: MaxAgeError}, nil
58+
}
59+
60+
maxAgeInt, err := convertMaxAgeStringToInt(maxAge)
61+
if err != nil {
62+
return nil, err
63+
}
64+
65+
if maxAgeInt < 10886400 {
66+
return &HSTSResponse{Message: MaxAgeError}, nil
67+
}
68+
69+
if !strings.Contains(hstsHeader, includeSubDomains) {
70+
return &HSTSResponse{Message: SubdomainsError}, nil
71+
}
72+
73+
if !strings.Contains(hstsHeader, preload) {
74+
return &HSTSResponse{Message: PreloadError}, nil
75+
}
76+
77+
return &HSTSResponse{
78+
Message: HstsSuccess,
79+
Compatible: true,
80+
HSTSHeader: hstsHeader,
81+
}, nil
82+
}
83+
84+
func extractMaxAgeFromHeader(header string) string {
85+
var maxAge strings.Builder
86+
87+
for _, b := range header {
88+
if unicode.IsDigit(b) {
89+
maxAge.WriteRune(b)
90+
}
91+
}
92+
93+
return maxAge.String()
94+
}
95+
96+
func convertMaxAgeStringToInt(maxAge string) (int, error) {
97+
return strconv.Atoi(maxAge)
98+
}

checks/hsts_test.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package checks
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/xray-web/web-check-api/testutils"
10+
)
11+
12+
func TestValidate(t *testing.T) {
13+
t.Parallel()
14+
15+
t.Run("given an empty header", func(t *testing.T) {
16+
t.Parallel()
17+
18+
client := testutils.MockClient(&http.Response{
19+
Header: http.Header{StrictTransportSecurity: []string{""}}})
20+
h := NewHsts(client)
21+
22+
actual, err := h.Validate(context.Background(), "test.com")
23+
assert.NoError(t, err)
24+
25+
assert.Equal(t, NilHeadersError, actual.Message)
26+
assert.False(t, actual.Compatible)
27+
assert.Empty(t, actual.HSTSHeader)
28+
})
29+
30+
t.Run("given a header without max age", func(t *testing.T) {
31+
t.Parallel()
32+
33+
client := testutils.MockClient(&http.Response{
34+
Header: http.Header{StrictTransportSecurity: []string{"includeSubDomains; preload"}}})
35+
h := NewHsts(client)
36+
37+
actual, err := h.Validate(context.Background(), "test.com")
38+
assert.NoError(t, err)
39+
40+
assert.Equal(t, MaxAgeError, actual.Message)
41+
assert.False(t, actual.Compatible)
42+
assert.Empty(t, actual.HSTSHeader)
43+
})
44+
45+
t.Run("given max age less than 10886400", func(t *testing.T) {
46+
t.Parallel()
47+
48+
client := testutils.MockClient(&http.Response{
49+
Header: http.Header{StrictTransportSecurity: []string{"max-age=47; includeSubDomains; preload"}}})
50+
h := NewHsts(client)
51+
52+
actual, err := h.Validate(context.Background(), "test.com")
53+
assert.NoError(t, err)
54+
55+
assert.Equal(t, MaxAgeError, actual.Message)
56+
assert.False(t, actual.Compatible)
57+
assert.Empty(t, actual.HSTSHeader)
58+
})
59+
60+
t.Run("given a header without includeSubDomains", func(t *testing.T) {
61+
t.Parallel()
62+
63+
client := testutils.MockClient(&http.Response{
64+
Header: http.Header{StrictTransportSecurity: []string{"max-age=47474747; preload"}}})
65+
h := NewHsts(client)
66+
67+
actual, err := h.Validate(context.Background(), "test.com")
68+
assert.NoError(t, err)
69+
70+
assert.Equal(t, SubdomainsError, actual.Message)
71+
assert.False(t, actual.Compatible)
72+
assert.Empty(t, actual.HSTSHeader)
73+
})
74+
75+
t.Run("given a header without preload", func(t *testing.T) {
76+
t.Parallel()
77+
78+
client := testutils.MockClient(&http.Response{
79+
Header: http.Header{StrictTransportSecurity: []string{"max-age=47474747; includeSubDomains"}}})
80+
h := NewHsts(client)
81+
82+
actual, err := h.Validate(context.Background(), "test.com")
83+
assert.NoError(t, err)
84+
85+
assert.Equal(t, PreloadError, actual.Message)
86+
assert.False(t, actual.Compatible)
87+
assert.Empty(t, actual.HSTSHeader)
88+
})
89+
90+
t.Run("given a valid header", func(t *testing.T) {
91+
t.Parallel()
92+
93+
client := testutils.MockClient(&http.Response{
94+
Header: http.Header{StrictTransportSecurity: []string{"max-age=47474747; includeSubDomains; preload"}}})
95+
h := NewHsts(client)
96+
97+
actual, err := h.Validate(context.Background(), "test.com")
98+
assert.NoError(t, err)
99+
100+
assert.Equal(t, HstsSuccess, actual.Message)
101+
assert.True(t, actual.Compatible)
102+
assert.NotEmpty(t, actual.HSTSHeader)
103+
})
104+
}
105+
106+
func TestExtractMaxAgeFromHeader(t *testing.T) {
107+
t.Parallel()
108+
109+
for _, tc := range []struct {
110+
name string
111+
header string
112+
expected string
113+
}{
114+
{"give valid header", "max-age=47474747; includeSubDomains; preload", "47474747"},
115+
{"given an empty header", "", ""},
116+
} {
117+
tc := tc
118+
t.Run(tc.name, func(t *testing.T) {
119+
t.Parallel()
120+
121+
actual := extractMaxAgeFromHeader(tc.header)
122+
assert.Equal(t, tc.expected, actual)
123+
})
124+
}
125+
}

handlers/hsts.go

+4-46
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,20 @@
11
package handlers
22

33
import (
4-
"fmt"
54
"net/http"
6-
"regexp"
7-
"strings"
8-
)
9-
10-
type HSTSResponse struct {
11-
Message string `json:"message"`
12-
Compatible bool `json:"compatible"`
13-
HSTSHeader string `json:"hstsHeader"`
14-
}
15-
16-
func checkHSTS(url string) (HSTSResponse, error) {
17-
client := &http.Client{}
18-
19-
req, err := http.NewRequest("HEAD", url, nil)
20-
if err != nil {
21-
return HSTSResponse{}, fmt.Errorf("error creating request: %s", err.Error())
22-
}
23-
24-
resp, err := client.Do(req)
25-
if err != nil {
26-
return HSTSResponse{}, fmt.Errorf("error making request: %s", err.Error())
27-
}
28-
defer resp.Body.Close()
295

30-
hstsHeader := resp.Header.Get("strict-transport-security")
31-
if hstsHeader == "" {
32-
return HSTSResponse{Message: "Site does not serve any HSTS headers."}, nil
33-
}
34-
35-
maxAgeMatch := regexp.MustCompile(`max-age=(\d+)`).FindStringSubmatch(hstsHeader)
36-
if maxAgeMatch == nil || len(maxAgeMatch) < 2 || maxAgeMatch[1] == "" || maxAgeMatch[1] < "10886400" {
37-
return HSTSResponse{Message: "HSTS max-age is less than 10886400."}, nil
38-
}
39-
40-
if !strings.Contains(hstsHeader, "includeSubDomains") {
41-
return HSTSResponse{Message: "HSTS header does not include all subdomains."}, nil
42-
}
43-
44-
if !strings.Contains(hstsHeader, "preload") {
45-
return HSTSResponse{Message: "HSTS header does not contain the preload directive."}, nil
46-
}
47-
48-
return HSTSResponse{Message: "Site is compatible with the HSTS preload list!", Compatible: true, HSTSHeader: hstsHeader}, nil
49-
}
6+
"github.com/xray-web/web-check-api/checks"
7+
)
508

51-
func HandleHsts() http.Handler {
9+
func HandleHsts(h *checks.Hsts) http.Handler {
5210
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5311
rawURL, err := extractURL(r)
5412
if err != nil {
5513
JSONError(w, ErrMissingURLParameter, http.StatusBadRequest)
5614
return
5715
}
5816

59-
result, err := checkHSTS(rawURL.String())
17+
result, err := h.Validate(r.Context(), rawURL.String())
6018
if err != nil {
6119
JSONError(w, err, http.StatusInternalServerError)
6220
return

handlers/hsts_test.go

+8-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package handlers
22

33
import (
4-
"encoding/json"
54
"net/http"
65
"net/http/httptest"
76
"testing"
@@ -11,18 +10,15 @@ import (
1110

1211
func TestHandleHsts(t *testing.T) {
1312
t.Parallel()
14-
req := httptest.NewRequest("GET", "/check-hsts?url=example.com", nil)
15-
rec := httptest.NewRecorder()
16-
HandleHsts().ServeHTTP(rec, req)
1713

18-
assert.Equal(t, http.StatusOK, rec.Code)
14+
t.Run("missing URL parameter", func(t *testing.T) {
15+
t.Parallel()
16+
req := httptest.NewRequest(http.MethodGet, "/check-hsts", nil)
17+
rec := httptest.NewRecorder()
1918

20-
var response HSTSResponse
21-
err := json.Unmarshal(rec.Body.Bytes(), &response)
22-
assert.NoError(t, err)
19+
HandleHsts(nil).ServeHTTP(rec, req)
2320

24-
assert.NotNil(t, response)
25-
assert.Equal(t, "Site does not serve any HSTS headers.", response.Message)
26-
assert.False(t, response.Compatible)
27-
assert.Empty(t, response.HSTSHeader)
21+
assert.Equal(t, http.StatusBadRequest, rec.Code)
22+
assert.JSONEq(t, `{"error": "missing URL parameter"}`, rec.Body.String())
23+
})
2824
}

server/server.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func (s *Server) routes() {
4141
s.mux.Handle("GET /api/firewall", handlers.HandleFirewall())
4242
s.mux.Handle("GET /api/get-ip", handlers.HandleGetIP(s.checks.IpAddress))
4343
s.mux.Handle("GET /api/headers", handlers.HandleGetHeaders())
44-
s.mux.Handle("GET /api/hsts", handlers.HandleHsts())
44+
s.mux.Handle("GET /api/hsts", handlers.HandleHsts(s.checks.Hsts))
4545
s.mux.Handle("GET /api/http-security", handlers.HandleHttpSecurity())
4646
s.mux.Handle("GET /api/legacy-rank", handlers.HandleLegacyRank(s.checks.LegacyRank))
4747
s.mux.Handle("GET /api/linked-pages", handlers.HandleGetLinks())

server/server_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
package server
22

33
import (
4+
"context"
45
"net/http"
56
"net/http/httptest"
67
"testing"
78
"time"
89

910
"github.com/stretchr/testify/assert"
1011
"github.com/xray-web/web-check-api/config"
11-
"golang.org/x/net/context"
1212
)
1313

1414
func TestServer(t *testing.T) {

0 commit comments

Comments
 (0)