Skip to content

Commit 79f1983

Browse files
authored
Merge pull request #6 from stacklok/add-validation-package
Add validation package with http and group subpackages
2 parents 03a2bab + c7dfe4e commit 79f1983

8 files changed

Lines changed: 480 additions & 2 deletions

File tree

go.mod

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ module github.com/stacklok/toolhive-core
22

33
go 1.25.6
44

5-
require go.uber.org/mock v0.6.0
5+
require (
6+
github.com/stretchr/testify v1.11.1
7+
go.uber.org/mock v0.6.0
8+
golang.org/x/net v0.49.0
9+
)
610

711
require (
812
github.com/davecgh/go-spew v1.1.1 // indirect
913
github.com/pmezard/go-difflib v1.0.0 // indirect
10-
github.com/stretchr/testify v1.11.1
14+
golang.org/x/text v0.33.0 // indirect
1115
gopkg.in/yaml.v3 v3.0.1 // indirect
1216
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
66
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
77
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
88
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
9+
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
10+
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
11+
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
12+
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
913
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1014
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1115
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

validation/group/doc.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/*
5+
Package group provides validation functions for group names.
6+
7+
Group names are used to organize and categorize resources. This package ensures
8+
group names follow consistent naming conventions for compatibility across systems.
9+
10+
# Name Validation
11+
12+
Validate group names against naming rules:
13+
14+
if err := group.ValidateName("my-team"); err != nil {
15+
// Handle invalid group name
16+
}
17+
18+
Valid group names must:
19+
- Be non-empty (not just whitespace)
20+
- Contain only lowercase alphanumeric characters, underscores, dashes, and spaces
21+
- Not contain null bytes
22+
- Not have leading or trailing whitespace
23+
- Not contain consecutive spaces
24+
25+
# Examples
26+
27+
Valid names:
28+
29+
"teamalpha"
30+
"team-alpha"
31+
"team_alpha_123"
32+
"team alpha"
33+
34+
Invalid names:
35+
36+
"" // empty
37+
"TeamAlpha" // uppercase
38+
"team@alpha" // special characters
39+
" teamalpha" // leading space
40+
"team alpha" // consecutive spaces
41+
*/
42+
package group

validation/group/group.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package group provides validation functions for group names.
5+
package group
6+
7+
import (
8+
"fmt"
9+
"regexp"
10+
"strings"
11+
)
12+
13+
var validNameRegex = regexp.MustCompile(`^[a-z0-9_\-\s]+$`)
14+
15+
// ValidateName validates that a group name only contains allowed characters:
16+
// lowercase alphanumeric, underscore, dash, and space.
17+
// It also enforces no leading/trailing/consecutive spaces and disallows null bytes.
18+
func ValidateName(name string) error {
19+
if name == "" || strings.TrimSpace(name) == "" {
20+
return fmt.Errorf("group name cannot be empty or consist only of whitespace")
21+
}
22+
23+
// Check for null bytes explicitly
24+
if strings.Contains(name, "\x00") {
25+
return fmt.Errorf("group name cannot contain null bytes")
26+
}
27+
28+
// Enforce lowercase-only group names
29+
if name != strings.ToLower(name) {
30+
return fmt.Errorf("group name must be lowercase")
31+
}
32+
33+
// Validate characters
34+
if !validNameRegex.MatchString(name) {
35+
return fmt.Errorf("group name can only contain lowercase alphanumeric characters, underscores, dashes, and spaces: %q", name)
36+
}
37+
38+
// Check for leading/trailing whitespace
39+
if strings.TrimSpace(name) != name {
40+
return fmt.Errorf("group name cannot have leading or trailing whitespace: %q", name)
41+
}
42+
43+
// Check for consecutive spaces
44+
if strings.Contains(name, " ") {
45+
return fmt.Errorf("group name cannot contain consecutive spaces: %q", name)
46+
}
47+
48+
return nil
49+
}

validation/group/group_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package group
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestValidateName(t *testing.T) {
13+
t.Parallel()
14+
tests := []struct {
15+
name string
16+
input string
17+
expectErr bool
18+
}{
19+
// Valid cases
20+
{"valid_simple_name", "teamalpha", false},
21+
{"valid_with_spaces", "team alpha", false},
22+
{"valid_with_dash_and_underscore", "team-alpha_123", false},
23+
24+
// Empty or whitespace-only
25+
{"empty_string", "", true},
26+
{"only_spaces", " ", true},
27+
28+
// Invalid characters
29+
{"invalid_special_characters", "team@alpha!", true},
30+
{"invalid_unicode", "团队🚀", true},
31+
32+
// Null byte
33+
{"null_byte", "team\x00alpha", true},
34+
35+
// Leading/trailing whitespace
36+
{"leading_space", " teamalpha", true},
37+
{"trailing_space", "teamalpha ", true},
38+
39+
// Consecutive spaces
40+
{"consecutive_spaces_middle", "team alpha", true},
41+
{"consecutive_spaces_start", " teamalpha", true},
42+
{"consecutive_spaces_end", "teamalpha ", true},
43+
44+
// Uppercase letters
45+
{"uppercase_letters", "TeamAlpha", true},
46+
47+
// Borderline valid
48+
{"single_char", "t", false},
49+
{"max_typical", "alpha team 2025 - squad_01", false},
50+
}
51+
52+
for _, tc := range tests {
53+
t.Run(tc.name, func(t *testing.T) {
54+
t.Parallel()
55+
err := ValidateName(tc.input)
56+
if tc.expectErr {
57+
assert.Error(t, err, "Expected error for input: %q", tc.input)
58+
} else {
59+
assert.NoError(t, err, "Did not expect error for input: %q", tc.input)
60+
}
61+
})
62+
}
63+
}

validation/http/doc.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/*
5+
Package http provides security-focused validation functions for HTTP headers and URIs.
6+
7+
This package helps prevent common security vulnerabilities such as HTTP header injection
8+
(CRLF injection) and malformed URI attacks by validating input against RFC specifications.
9+
10+
# Header Validation
11+
12+
Validate HTTP header names and values per RFC 7230:
13+
14+
if err := http.ValidateHeaderName("X-Custom-Header"); err != nil {
15+
// Handle invalid header name
16+
}
17+
18+
if err := http.ValidateHeaderValue("Bearer token123"); err != nil {
19+
// Handle invalid header value
20+
}
21+
22+
The validators check for:
23+
- CRLF injection attempts (\r\n sequences)
24+
- Control characters
25+
- RFC 7230 token compliance for header names
26+
- Length limits to prevent DoS (256 bytes for names, 8192 for values)
27+
28+
# Resource URI Validation
29+
30+
Validate URIs for use as OAuth 2.0 resource indicators per RFC 8707:
31+
32+
if err := http.ValidateResourceURI("https://api.example.com/v1"); err != nil {
33+
// Handle invalid URI
34+
}
35+
36+
Resource URIs must:
37+
- Include a scheme (typically http or https)
38+
- Include a host
39+
- Not contain fragment identifiers (#)
40+
*/
41+
package http

validation/http/http.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package http provides validation functions for HTTP headers and URIs.
5+
package http
6+
7+
import (
8+
"fmt"
9+
"net/url"
10+
11+
"golang.org/x/net/http/httpguts"
12+
)
13+
14+
// ValidateHeaderName validates that a string is a valid HTTP header name per RFC 7230.
15+
// It checks for CRLF injection, control characters, and ensures RFC token compliance.
16+
func ValidateHeaderName(name string) error {
17+
if name == "" {
18+
return fmt.Errorf("header name cannot be empty")
19+
}
20+
21+
// Length limit to prevent DoS
22+
if len(name) > 256 {
23+
return fmt.Errorf("header name exceeds maximum length of 256 bytes")
24+
}
25+
26+
// Use httpguts validation (same as Go's HTTP/2 implementation)
27+
if !httpguts.ValidHeaderFieldName(name) {
28+
return fmt.Errorf("invalid HTTP header name: contains invalid characters")
29+
}
30+
31+
return nil
32+
}
33+
34+
// ValidateHeaderValue validates that a string is a valid HTTP header value per RFC 7230.
35+
// It checks for CRLF injection and control characters.
36+
func ValidateHeaderValue(value string) error {
37+
if value == "" {
38+
return fmt.Errorf("header value cannot be empty")
39+
}
40+
41+
// Length limit to prevent DoS (common HTTP server limit)
42+
if len(value) > 8192 {
43+
return fmt.Errorf("header value exceeds maximum length of 8192 bytes")
44+
}
45+
46+
// Use httpguts validation
47+
if !httpguts.ValidHeaderFieldValue(value) {
48+
return fmt.Errorf("invalid HTTP header value: contains control characters")
49+
}
50+
51+
return nil
52+
}
53+
54+
// ValidateResourceURI validates that a resource URI conforms to RFC 8707 requirements
55+
// for canonical URIs used in OAuth 2.0 resource indicators.
56+
//
57+
// A valid canonical URI must:
58+
// - Include a scheme (http/https)
59+
// - Include a host
60+
// - Not contain fragments
61+
func ValidateResourceURI(resourceURI string) error {
62+
if resourceURI == "" {
63+
return fmt.Errorf("resource URI cannot be empty")
64+
}
65+
66+
// Parse the URI
67+
parsed, err := url.Parse(resourceURI)
68+
if err != nil {
69+
return fmt.Errorf("invalid resource URI: %w", err)
70+
}
71+
72+
// Must have a scheme
73+
if parsed.Scheme == "" {
74+
return fmt.Errorf("resource URI must include a scheme (e.g., https://): %s", resourceURI)
75+
}
76+
77+
// Must have a host
78+
if parsed.Host == "" {
79+
return fmt.Errorf("resource URI must include a host: %s", resourceURI)
80+
}
81+
82+
// Must not contain fragments
83+
if parsed.Fragment != "" {
84+
return fmt.Errorf("resource URI must not contain fragments (#): %s", resourceURI)
85+
}
86+
87+
return nil
88+
}

0 commit comments

Comments
 (0)