registry/internal/validators/utils.go
Radoslav Dimitrov 8759d9d8c0
ci: bump golangci-lint to v2.13.1 to fix panic on Go 1.27 (#1561)
CI is red on `main` and on every open PR (including the Dependabot PRs
#1531, #1552–#1554) with:

```
panic: file requires newer Go version go1.27 (application built with go1.26)
golangci-lint exit with code 2
```

## Cause

`actions/setup-go` with `go-version: 'stable'` started resolving to
**go1.27.0**. The pinned golangci-lint **v2.11.4** release binary is
built with **go1.26**, and its `go/types` refuses to typecheck source
files declaring a newer language version. Nothing in the failing PRs is
at fault.

## Fix

Bump to **golangci-lint v2.13.1**, whose release binaries are built with
go1.27, so linting works under `stable` again.

That bump also pulls in newer linter versions, which surface 57 findings
this tree did not have before. Handled as follows:

| Linter | Count | Resolution |
|---|---|---|
| `goconst` | 50 | 39 were repeated literals in `_test.go` files —
goconst started counting those in v2.12.0 (golangci-lint#6480 exposed
`ignore-tests`). Fixture data like `"1.0.0"` (89 occurrences) is not a
missing constant, so `goconst.ignore-tests: true`. The other 11 were
real duplication in production code (OpenAPI tags, the `bearer` security
scheme name, `$ref`, `http`/`https`) and became named constants. |
| `gosec` G710 | 1 | **True positive, fixed.** See below. |
| `staticcheck` SA1019 | 6 | Go 1.26 deprecated `ecdsa.PublicKey.X/Y`
and `PrivateKey.D`. Annotated with the same targeted `nolint` +
rationale already used for `ScalarBaseMult` in
`cmd/publisher/auth/common.go`. |

### The open redirect is real

`TrailingSlashMiddleware` copied the entire `*url.URL` before rewriting
the path. For an absolute-form request URI — legal for a Go HTTP server
to parse, e.g. `GET http://evil.com/foo/ HTTP/1.1` — `r.URL.Scheme` and
`r.URL.Host` are populated, and they were echoed straight back in the
`Location` header. The redirect target is now built from the cleaned
path and query alone. The existing `path.Clean` hardening for
GHSA-v8vw-gw5j-w7m6 (protocol-relative `//evil.com/`) is unchanged.

### Also

Switched `gomodguard` → `gomodguard_v2`; the former is deprecated as of
v2.12.0 and was emitting a warning on every run.

## Verification

- `golangci-lint v2.13.1` against this tree: **0 issues**, no
deprecation warnings
- `make validate`: all validations passed
- `go build ./...` and tests for every touched package pass (DB-backed
tests need Postgres; left to CI)

## Note on migrating the ecdsa key paths

Moving those six sites to `PublicKey.Bytes` /
`ParseUncompressedPublicKey` / `crypto/ecdh` touches key parsing and
signature verification. It should be its own reviewed PR rather than
riding along on a CI unblock. Happy to open one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 00:14:13 +03:00

198 lines
5.4 KiB
Go

package validators
import (
"net/url"
"regexp"
"strings"
)
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
var (
// Regular expressions for validating repository URLs
// These regex patterns ensure the URL is in the format of a valid GitHub or GitLab repository
// For example: // - GitHub: https://github.com/user/repo
githubURLRegex = regexp.MustCompile(`^https?://(www\.)?github\.com/[\w.-]+/[\w.-]+/?$`)
gitlabURLRegex = regexp.MustCompile(`^https?://(www\.)?gitlab\.com/[\w.-]+/[\w.-]+/?$`)
)
// IsValidRepositoryURL checks if the given URL is valid for the specified repository source
func IsValidRepositoryURL(source RepositorySource, url string) bool {
switch source {
case SourceGitHub:
return githubURLRegex.MatchString(url)
case SourceGitLab:
return gitlabURLRegex.MatchString(url)
}
return false
}
// HasNoSpaces checks if a string contains no spaces
func HasNoSpaces(s string) bool {
return !strings.Contains(s, " ")
}
// extractTemplateVariables extracts template variables from a URL string
// e.g., "http://{host}:{port}/mcp" returns ["host", "port"]
func extractTemplateVariables(url string) []string {
re := regexp.MustCompile(`\{([^}]+)\}`)
matches := re.FindAllStringSubmatch(url, -1)
var variables []string
for _, match := range matches {
if len(match) > 1 {
variables = append(variables, match[1])
}
}
return variables
}
// replaceTemplateVariables replaces template variables with placeholder values for URL validation
func replaceTemplateVariables(rawURL string) string {
// Replace common template variables with valid placeholder values for parsing
templateReplacements := map[string]string{
"{host}": "example.com",
"{port}": "8080",
"{path}": "api",
"{protocol}": schemeHTTP,
"{scheme}": schemeHTTP,
}
result := rawURL
for placeholder, replacement := range templateReplacements {
result = strings.ReplaceAll(result, placeholder, replacement)
}
// Handle any remaining {variable} patterns with context-appropriate placeholders
// If the variable is in a port position (after a colon in the host), use a numeric placeholder
// Pattern: :/{variable} or :{variable}/ or :{variable} at end
portRe := regexp.MustCompile(`:(\{[^}]+\})(/|$)`)
result = portRe.ReplaceAllString(result, ":8080$2")
// Replace any other remaining {variable} patterns with generic placeholder
re := regexp.MustCompile(`\{[^}]+\}`)
result = re.ReplaceAllString(result, "placeholder")
return result
}
// IsValidURL checks if a URL is in valid format (basic structure validation)
func IsValidURL(rawURL string) bool {
// Replace template variables with placeholders for parsing
testURL := replaceTemplateVariables(rawURL)
// Parse the URL
u, err := url.Parse(testURL)
if err != nil {
return false
}
// Check if scheme is present (http or https)
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
return false
}
if u.Host == "" {
return false
}
return true
}
// IsValidSubfolderPath checks if a subfolder path is valid
func IsValidSubfolderPath(path string) bool {
// Empty path is valid (subfolder is optional)
if path == "" {
return true
}
// Must not start with / (must be relative)
if strings.HasPrefix(path, "/") {
return false
}
// Must not end with / (clean path format)
if strings.HasSuffix(path, "/") {
return false
}
// Check for valid path characters (alphanumeric, dash, underscore, dot, forward slash)
validPathRegex := regexp.MustCompile(`^[a-zA-Z0-9\-_./]+$`)
if !validPathRegex.MatchString(path) {
return false
}
// Check that path segments are valid
segments := strings.Split(path, "/")
for _, segment := range segments {
// Disallow empty segments ("//"), current dir ("."), and parent dir ("..")
if segment == "" || segment == "." || segment == ".." {
return false
}
}
return true
}
// IsValidRemoteURL checks if a URL is valid for remotes (stricter than packages - no localhost allowed)
func IsValidRemoteURL(rawURL string) bool {
// First check basic URL structure
if !IsValidURL(rawURL) {
return false
}
// Replace template variables with placeholders before parsing for localhost check
testURL := replaceTemplateVariables(rawURL)
// Parse the URL to check for localhost restriction
u, err := url.Parse(testURL)
if err != nil {
return false
}
// Reject localhost URLs for remotes (security/production concerns)
hostname := u.Hostname()
if hostname == "localhost" || hostname == "127.0.0.1" || strings.HasSuffix(hostname, ".localhost") {
return false
}
if u.Scheme != schemeHTTPS {
return false
}
return true
}
// IsValidTemplatedURL validates a URL with template variables against available variables
// For packages: validates that template variables reference package arguments or environment variables
// For remotes: validates that template variables reference the transport's variables map
func IsValidTemplatedURL(rawURL string, availableVariables []string) bool {
// First check basic URL structure
if !IsValidURL(rawURL) {
return false
}
// Extract template variables from URL
templateVars := extractTemplateVariables(rawURL)
// If no templates are found, it's a valid static URL
if len(templateVars) == 0 {
return true
}
// Validate that all template variables are available
availableSet := make(map[string]bool)
for _, v := range availableVariables {
availableSet[v] = true
}
for _, templateVar := range templateVars {
if !availableSet[templateVar] {
return false
}
}
return true
}