Centralize HTTP-01 token validation
- commit
- 32d0f87bbeb2099af9435000464df96d3086bac4
- author
- brice · · 2026-04-29 17:45 UTC
- parent
- 8070205
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +36 -22
M internal/adapters/in/http/acme/handler.go +3 -7
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | // Prefix is the path prefix for ACME HTTP-01 challenge requests. | |
| @@ -41,7 +43,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 41 | 43 | return | |
| 42 | 44 | } | |
| 43 | 45 | token := strings.TrimPrefix(r.URL.Path, Prefix) | |
| 44 | - | if !safeToken(token) { | |
| 46 | + | if !domain.IsValidHTTP01Token(token) { | |
| 45 | 47 | http.NotFound(w, r) | |
| 46 | 48 | return | |
| 47 | 49 | } | |
| @@ -67,9 +69,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 67 | 69 | ||
| 68 | 70 | _, _ = w.Write([]byte(keyAuth)) | |
| 69 | 71 | } | |
| 70 | - | ||
| 71 | - | // safeToken validates that the token does not contain path traversal or special | |
| 72 | - | // characters that could be used to access files outside the challenge directory. | |
| 73 | - | func safeToken(token string) bool { | |
| 74 | - | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |
| 75 | - | } | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "strings" | 8 | "strings" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | // Prefix is the path prefix for ACME HTTP-01 challenge requests. | 13 | // Prefix is the path prefix for ACME HTTP-01 challenge requests. | ||
| @@ -41,7 +43,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 41 | return | 43 | return | ||
| 42 | } | 44 | } | ||
| 43 | token := strings.TrimPrefix(r.URL.Path, Prefix) | 45 | token := strings.TrimPrefix(r.URL.Path, Prefix) | ||
| 44 | - | if !safeToken(token) { | 46 | + | if !domain.IsValidHTTP01Token(token) { |
| 45 | http.NotFound(w, r) | 47 | http.NotFound(w, r) | ||
| 46 | return | 48 | return | ||
| 47 | } | 49 | } | ||
| @@ -67,9 +69,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||||
| 67 | 69 | ||||
| 68 | _, _ = w.Write([]byte(keyAuth)) | 70 | _, _ = w.Write([]byte(keyAuth)) | ||
| 69 | } | 71 | } | ||
| 70 | - | ||||
| 71 | - | // safeToken validates that the token does not contain path traversal or special | |||
| 72 | - | // characters that could be used to access files outside the challenge directory. | |||
| 73 | - | func safeToken(token string) bool { | |||
| 74 | - | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |||
| 75 | - | } | |||
M internal/domain/public_tls.go +4 -0
| @@ -14,6 +14,10 @@ const ( | |||
| 14 | 14 | ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | func IsValidHTTP01Token(token string) bool { | |
| 18 | + | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |
| 19 | + | } | |
| 20 | + | ||
| 17 | 21 | func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) { | |
| 18 | 22 | switch strings.TrimSpace(value) { | |
| 19 | 23 | case "", string(ACMEChallengeAuto): | |
| @@ -14,6 +14,10 @@ const ( | |||||
| 14 | ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01" | 14 | ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | func IsValidHTTP01Token(token string) bool { | |||
| 18 | + | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |||
| 19 | + | } | |||
| 20 | + | ||||
| 17 | func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) { | 21 | func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) { | ||
| 18 | switch strings.TrimSpace(value) { | 22 | switch strings.TrimSpace(value) { | ||
| 19 | case "", string(ACMEChallengeAuto): | 23 | case "", string(ACMEChallengeAuto): | ||
M internal/domain/public_tls_test.go +21 -0
| @@ -8,6 +8,27 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | func TestIsValidHTTP01Token(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | token string | |
| 15 | + | want bool | |
| 16 | + | }{ | |
| 17 | + | {name: "valid", token: "abc123_-token", want: true}, | |
| 18 | + | {name: "empty", token: "", want: false}, | |
| 19 | + | {name: "slash", token: "abc/def", want: false}, | |
| 20 | + | {name: "backslash", token: `abc\def`, want: false}, | |
| 21 | + | {name: "dot dot", token: "abc..def", want: false}, | |
| 22 | + | {name: "nul", token: "abc\x00def", want: false}, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | for _, tt := range tests { | |
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |
| 27 | + | assert.Equal(t, tt.want, IsValidHTTP01Token(tt.token)) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 11 | 32 | func TestACMEChallengeModeParse(t *testing.T) { | |
| 12 | 33 | tests := []struct { | |
| 13 | 34 | input string | |
| @@ -8,6 +8,27 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | + | func TestIsValidHTTP01Token(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | token string | |||
| 15 | + | want bool | |||
| 16 | + | }{ | |||
| 17 | + | {name: "valid", token: "abc123_-token", want: true}, | |||
| 18 | + | {name: "empty", token: "", want: false}, | |||
| 19 | + | {name: "slash", token: "abc/def", want: false}, | |||
| 20 | + | {name: "backslash", token: `abc\def`, want: false}, | |||
| 21 | + | {name: "dot dot", token: "abc..def", want: false}, | |||
| 22 | + | {name: "nul", token: "abc\x00def", want: false}, | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | for _, tt := range tests { | |||
| 26 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 27 | + | assert.Equal(t, tt.want, IsValidHTTP01Token(tt.token)) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 11 | func TestACMEChallengeModeParse(t *testing.T) { | 32 | func TestACMEChallengeModeParse(t *testing.T) { | ||
| 12 | tests := []struct { | 33 | tests := []struct { | ||
| 13 | input string | 34 | input string | ||
M internal/usecase/publictls/http01.go +8 -15
| @@ -2,8 +2,9 @@ package publictls | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "strings" | |
| 6 | 5 | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key | |
| @@ -21,9 +22,9 @@ func NewHTTP01Challenges() *HTTP01Challenges { | |||
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | // Present stores the key authorization for the given token. It silently | |
| 24 | - | // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values. | |
| 25 | + | // ignores unsafe tokens and empty keyAuth values. | |
| 25 | 26 | func (c *HTTP01Challenges) Present(token, keyAuth string) { | |
| 26 | - | if !safeHTTP01Token(token) || keyAuth == "" { | |
| 27 | + | if !domain.IsValidHTTP01Token(token) || keyAuth == "" { | |
| 27 | 28 | return | |
| 28 | 29 | } | |
| 29 | 30 | c.mu.Lock() | |
| @@ -32,9 +33,9 @@ func (c *HTTP01Challenges) Present(token, keyAuth string) { | |||
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // CleanUp removes the stored key authorization for the given token. It silently | |
| 35 | - | // ignores unsafe tokens (see safeHTTP01Token). | |
| 36 | + | // ignores unsafe tokens. | |
| 36 | 37 | func (c *HTTP01Challenges) CleanUp(token string) { | |
| 37 | - | if !safeHTTP01Token(token) { | |
| 38 | + | if !domain.IsValidHTTP01Token(token) { | |
| 38 | 39 | return | |
| 39 | 40 | } | |
| 40 | 41 | c.mu.Lock() | |
| @@ -43,10 +44,9 @@ func (c *HTTP01Challenges) CleanUp(token string) { | |||
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | 46 | // Get returns the key authorization for the given token, and a boolean | |
| 46 | - | // indicating whether it was found. It returns false for unsafe tokens | |
| 47 | - | // (see safeHTTP01Token). | |
| 47 | + | // indicating whether it was found. It returns false for unsafe tokens. | |
| 48 | 48 | func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | |
| 49 | - | if !safeHTTP01Token(token) { | |
| 49 | + | if !domain.IsValidHTTP01Token(token) { | |
| 50 | 50 | return "", false | |
| 51 | 51 | } | |
| 52 | 52 | c.mu.RLock() | |
| @@ -54,10 +54,3 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | |||
| 54 | 54 | keyAuth, ok := c.data[token] | |
| 55 | 55 | return keyAuth, ok | |
| 56 | 56 | } | |
| 57 | - | ||
| 58 | - | // safeHTTP01Token validates an ACME HTTP-01 challenge token. | |
| 59 | - | // It rejects empty tokens, tokens containing "/", "\", "..", or NUL, | |
| 60 | - | // which could allow path traversal or invalid challenge paths. | |
| 61 | - | func safeHTTP01Token(token string) bool { | |
| 62 | - | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |
| 63 | - | } | |
| @@ -2,8 +2,9 @@ package publictls | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "strings" | |||
| 6 | "sync" | 5 | "sync" | ||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key | 10 | // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key | ||
| @@ -21,9 +22,9 @@ func NewHTTP01Challenges() *HTTP01Challenges { | |||||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | // Present stores the key authorization for the given token. It silently | 24 | // Present stores the key authorization for the given token. It silently | ||
| 24 | - | // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values. | 25 | + | // ignores unsafe tokens and empty keyAuth values. |
| 25 | func (c *HTTP01Challenges) Present(token, keyAuth string) { | 26 | func (c *HTTP01Challenges) Present(token, keyAuth string) { | ||
| 26 | - | if !safeHTTP01Token(token) || keyAuth == "" { | 27 | + | if !domain.IsValidHTTP01Token(token) || keyAuth == "" { |
| 27 | return | 28 | return | ||
| 28 | } | 29 | } | ||
| 29 | c.mu.Lock() | 30 | c.mu.Lock() | ||
| @@ -32,9 +33,9 @@ func (c *HTTP01Challenges) Present(token, keyAuth string) { | |||||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | // CleanUp removes the stored key authorization for the given token. It silently | 35 | // CleanUp removes the stored key authorization for the given token. It silently | ||
| 35 | - | // ignores unsafe tokens (see safeHTTP01Token). | 36 | + | // ignores unsafe tokens. |
| 36 | func (c *HTTP01Challenges) CleanUp(token string) { | 37 | func (c *HTTP01Challenges) CleanUp(token string) { | ||
| 37 | - | if !safeHTTP01Token(token) { | 38 | + | if !domain.IsValidHTTP01Token(token) { |
| 38 | return | 39 | return | ||
| 39 | } | 40 | } | ||
| 40 | c.mu.Lock() | 41 | c.mu.Lock() | ||
| @@ -43,10 +44,9 @@ func (c *HTTP01Challenges) CleanUp(token string) { | |||||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | // Get returns the key authorization for the given token, and a boolean | 46 | // Get returns the key authorization for the given token, and a boolean | ||
| 46 | - | // indicating whether it was found. It returns false for unsafe tokens | 47 | + | // indicating whether it was found. It returns false for unsafe tokens. |
| 47 | - | // (see safeHTTP01Token). | |||
| 48 | func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | 48 | func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | ||
| 49 | - | if !safeHTTP01Token(token) { | 49 | + | if !domain.IsValidHTTP01Token(token) { |
| 50 | return "", false | 50 | return "", false | ||
| 51 | } | 51 | } | ||
| 52 | c.mu.RLock() | 52 | c.mu.RLock() | ||
| @@ -54,10 +54,3 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | |||||
| 54 | keyAuth, ok := c.data[token] | 54 | keyAuth, ok := c.data[token] | ||
| 55 | return keyAuth, ok | 55 | return keyAuth, ok | ||
| 56 | } | 56 | } | ||
| 57 | - | ||||
| 58 | - | // safeHTTP01Token validates an ACME HTTP-01 challenge token. | |||
| 59 | - | // It rejects empty tokens, tokens containing "/", "\", "..", or NUL, | |||
| 60 | - | // which could allow path traversal or invalid challenge paths. | |||
| 61 | - | func safeHTTP01Token(token string) bool { | |||
| 62 | - | return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |||
| 63 | - | } | |||