detached 32d0f87

Centralize HTTP-01 token validation

commit
32d0f87bbeb2099af9435000464df96d3086bac4
author
brice · · 2026-04-29 17:45 UTC
parent
8070205
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
66 "context"
77 "net/http"
88 "strings"
9+
10+ "github.com/bnema/gordon/internal/domain"
911 )
1012
1113 // 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) {
4143 return
4244 }
4345 token := strings.TrimPrefix(r.URL.Path, Prefix)
44- if !safeToken(token) {
46+ if !domain.IsValidHTTP01Token(token) {
4547 http.NotFound(w, r)
4648 return
4749 }
@@ -67,9 +69,3 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6769
6870 _, _ = w.Write([]byte(keyAuth))
6971 }
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 return43 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 return48 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 (
1414 ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01"
1515 )
1616
17+func IsValidHTTP01Token(token string) bool {
18+ return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
19+}
20+
1721 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
1822 switch strings.TrimSpace(value) {
1923 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 (
88 "github.com/stretchr/testify/require"
99 )
1010
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+
1132 func TestACMEChallengeModeParse(t *testing.T) {
1233 tests := []struct {
1334 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 string34 input string
M internal/usecase/publictls/http01.go +8 -15
@@ -2,8 +2,9 @@ package publictls
22
33 import (
44 "context"
5- "strings"
65 "sync"
6+
7+ "github.com/bnema/gordon/internal/domain"
78 )
89
910 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
@@ -21,9 +22,9 @@ func NewHTTP01Challenges() *HTTP01Challenges {
2122 }
2223
2324 // 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.
2526 func (c *HTTP01Challenges) Present(token, keyAuth string) {
26- if !safeHTTP01Token(token) || keyAuth == "" {
27+ if !domain.IsValidHTTP01Token(token) || keyAuth == "" {
2728 return
2829 }
2930 c.mu.Lock()
@@ -32,9 +33,9 @@ func (c *HTTP01Challenges) Present(token, keyAuth string) {
3233 }
3334
3435 // CleanUp removes the stored key authorization for the given token. It silently
35-// ignores unsafe tokens (see safeHTTP01Token).
36+// ignores unsafe tokens.
3637 func (c *HTTP01Challenges) CleanUp(token string) {
37- if !safeHTTP01Token(token) {
38+ if !domain.IsValidHTTP01Token(token) {
3839 return
3940 }
4041 c.mu.Lock()
@@ -43,10 +44,9 @@ func (c *HTTP01Challenges) CleanUp(token string) {
4344 }
4445
4546 // 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.
4848 func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
49- if !safeHTTP01Token(token) {
49+ if !domain.IsValidHTTP01Token(token) {
5050 return "", false
5151 }
5252 c.mu.RLock()
@@ -54,10 +54,3 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
5454 keyAuth, ok := c.data[token]
5555 return keyAuth, ok
5656 }
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 key10 // 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 silently24 // 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 return28 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 silently35 // 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 return39 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 boolean46 // Get returns the key authorization for the given token, and a boolean
46-// indicating whether it was found. It returns false for unsafe tokens47+// 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 "", false50 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, ok55 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-}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close