Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ module github.com/stacklok/toolhive-core

go 1.25.6

require go.uber.org/mock v0.6.0
require (
github.com/stretchr/testify v1.11.1
go.uber.org/mock v0.6.0
golang.org/x/net v0.49.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.11.1
golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
42 changes: 42 additions & 0 deletions validation/group/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

/*
Package group provides validation functions for group names.

Group names are used to organize and categorize resources. This package ensures
group names follow consistent naming conventions for compatibility across systems.

# Name Validation

Validate group names against naming rules:

if err := group.ValidateName("my-team"); err != nil {
// Handle invalid group name
}

Valid group names must:
- Be non-empty (not just whitespace)
- Contain only lowercase alphanumeric characters, underscores, dashes, and spaces
- Not contain null bytes
- Not have leading or trailing whitespace
- Not contain consecutive spaces

# Examples

Valid names:

"teamalpha"
"team-alpha"
"team_alpha_123"
"team alpha"

Invalid names:

"" // empty
"TeamAlpha" // uppercase
"team@alpha" // special characters
" teamalpha" // leading space
"team alpha" // consecutive spaces
*/
package group
49 changes: 49 additions & 0 deletions validation/group/group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package group provides validation functions for group names.
package group

import (
"fmt"
"regexp"
"strings"
)

var validNameRegex = regexp.MustCompile(`^[a-z0-9_\-\s]+$`)

// ValidateName validates that a group name only contains allowed characters:
// lowercase alphanumeric, underscore, dash, and space.
// It also enforces no leading/trailing/consecutive spaces and disallows null bytes.
func ValidateName(name string) error {
if name == "" || strings.TrimSpace(name) == "" {
return fmt.Errorf("group name cannot be empty or consist only of whitespace")
}

// Check for null bytes explicitly
if strings.Contains(name, "\x00") {
return fmt.Errorf("group name cannot contain null bytes")
}

// Enforce lowercase-only group names
if name != strings.ToLower(name) {
return fmt.Errorf("group name must be lowercase")
}

// Validate characters
if !validNameRegex.MatchString(name) {
return fmt.Errorf("group name can only contain lowercase alphanumeric characters, underscores, dashes, and spaces: %q", name)
}

// Check for leading/trailing whitespace
if strings.TrimSpace(name) != name {
return fmt.Errorf("group name cannot have leading or trailing whitespace: %q", name)
}

// Check for consecutive spaces
if strings.Contains(name, " ") {
return fmt.Errorf("group name cannot contain consecutive spaces: %q", name)
}

return nil
}
63 changes: 63 additions & 0 deletions validation/group/group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package group

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestValidateName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expectErr bool
}{
// Valid cases
{"valid_simple_name", "teamalpha", false},
{"valid_with_spaces", "team alpha", false},
{"valid_with_dash_and_underscore", "team-alpha_123", false},

// Empty or whitespace-only
{"empty_string", "", true},
{"only_spaces", " ", true},

// Invalid characters
{"invalid_special_characters", "team@alpha!", true},
{"invalid_unicode", "团队🚀", true},

// Null byte
{"null_byte", "team\x00alpha", true},

// Leading/trailing whitespace
{"leading_space", " teamalpha", true},
{"trailing_space", "teamalpha ", true},

// Consecutive spaces
{"consecutive_spaces_middle", "team alpha", true},
{"consecutive_spaces_start", " teamalpha", true},
{"consecutive_spaces_end", "teamalpha ", true},

// Uppercase letters
{"uppercase_letters", "TeamAlpha", true},

// Borderline valid
{"single_char", "t", false},
{"max_typical", "alpha team 2025 - squad_01", false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := ValidateName(tc.input)
if tc.expectErr {
assert.Error(t, err, "Expected error for input: %q", tc.input)
} else {
assert.NoError(t, err, "Did not expect error for input: %q", tc.input)
}
})
}
}
41 changes: 41 additions & 0 deletions validation/http/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

/*
Package http provides security-focused validation functions for HTTP headers and URIs.

This package helps prevent common security vulnerabilities such as HTTP header injection
(CRLF injection) and malformed URI attacks by validating input against RFC specifications.

# Header Validation

Validate HTTP header names and values per RFC 7230:

if err := http.ValidateHeaderName("X-Custom-Header"); err != nil {
// Handle invalid header name
}

if err := http.ValidateHeaderValue("Bearer token123"); err != nil {
// Handle invalid header value
}

The validators check for:
- CRLF injection attempts (\r\n sequences)
- Control characters
- RFC 7230 token compliance for header names
- Length limits to prevent DoS (256 bytes for names, 8192 for values)

# Resource URI Validation

Validate URIs for use as OAuth 2.0 resource indicators per RFC 8707:

if err := http.ValidateResourceURI("https://api.example.com/v1"); err != nil {
// Handle invalid URI
}

Resource URIs must:
- Include a scheme (typically http or https)
- Include a host
- Not contain fragment identifiers (#)
*/
package http
88 changes: 88 additions & 0 deletions validation/http/http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

// Package http provides validation functions for HTTP headers and URIs.
package http

import (
"fmt"
"net/url"

"golang.org/x/net/http/httpguts"
)

// ValidateHeaderName validates that a string is a valid HTTP header name per RFC 7230.
// It checks for CRLF injection, control characters, and ensures RFC token compliance.
func ValidateHeaderName(name string) error {
if name == "" {
return fmt.Errorf("header name cannot be empty")
}

// Length limit to prevent DoS
if len(name) > 256 {
return fmt.Errorf("header name exceeds maximum length of 256 bytes")
}

// Use httpguts validation (same as Go's HTTP/2 implementation)
if !httpguts.ValidHeaderFieldName(name) {
return fmt.Errorf("invalid HTTP header name: contains invalid characters")
}

return nil
}

// ValidateHeaderValue validates that a string is a valid HTTP header value per RFC 7230.
// It checks for CRLF injection and control characters.
func ValidateHeaderValue(value string) error {
if value == "" {
return fmt.Errorf("header value cannot be empty")
}

// Length limit to prevent DoS (common HTTP server limit)
if len(value) > 8192 {
return fmt.Errorf("header value exceeds maximum length of 8192 bytes")
}

// Use httpguts validation
if !httpguts.ValidHeaderFieldValue(value) {
return fmt.Errorf("invalid HTTP header value: contains control characters")
}

return nil
}

// ValidateResourceURI validates that a resource URI conforms to RFC 8707 requirements
// for canonical URIs used in OAuth 2.0 resource indicators.
//
// A valid canonical URI must:
// - Include a scheme (http/https)
// - Include a host
// - Not contain fragments
func ValidateResourceURI(resourceURI string) error {
if resourceURI == "" {
return fmt.Errorf("resource URI cannot be empty")
}

// Parse the URI
parsed, err := url.Parse(resourceURI)
if err != nil {
return fmt.Errorf("invalid resource URI: %w", err)
}

// Must have a scheme
if parsed.Scheme == "" {
return fmt.Errorf("resource URI must include a scheme (e.g., https://): %s", resourceURI)
}

// Must have a host
if parsed.Host == "" {
return fmt.Errorf("resource URI must include a host: %s", resourceURI)
}

// Must not contain fragments
if parsed.Fragment != "" {
return fmt.Errorf("resource URI must not contain fragments (#): %s", resourceURI)
}

return nil
}
Loading
Loading