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
3 changes: 2 additions & 1 deletion spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ func (s *SpecValidator) Validate(data any) (*Result, *Result) {
errs.Merge(s.validateNonEmptyPathParamNames())

// errs.Merge(s.validateRefNoSibling()) // warning only
errs.Merge(s.validateReferenced()) // warning only
errs.Merge(s.validateReferenced()) // warning only
errs.Merge(s.validateDubiousRefs()) // warning only

return errs, warnings
}
Expand Down
20 changes: 20 additions & 0 deletions spec_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,18 @@ const (
// UnusedResponseWarning ...
UnusedResponseWarning = "response %q is not used anywhere"

// DubiousAbsoluteRefWarning flags a $ref pointing to an absolute local file location that escapes the
// spec's base path. Absolute local references are legitimate when they stay beneath the base path
// (flattening/expansion introduces such anchors for cyclical $refs), but an absolute reference that
// escapes the base path - or a file:// reference in a spec with no known base - may indicate an
// unsafe or adversarial spec.
DubiousAbsoluteRefWarning = "$ref %q points to an absolute or local file location that escapes the spec's base path: this may be unsafe with adversarial specs"

// DubiousMultipleHostsWarning flags a spec whose remote $refs resolve to several distinct hosts.
// A single consistent remote host is common and legitimate; references spread across multiple hosts
// may indicate an unsafe or adversarial spec.
DubiousMultipleHostsWarning = "$ref values point to %d distinct remote hosts (%s): a spec referencing multiple hosts may be unsafe"

InvalidObject = "expected an object in %q.%s"
)

Expand Down Expand Up @@ -404,3 +416,11 @@ func someParametersBrokenMsg(path, method, operationID string) errors.Error {
func refShouldNotHaveSiblingsMsg(path, operationID string) errors.Error {
return errors.New(errors.CompositeErrorCode, RefShouldNotHaveSiblingsWarning, operationID, path)
}

func dubiousAbsoluteRefMsg(ref string) errors.Error {
return errors.New(errors.CompositeErrorCode, DubiousAbsoluteRefWarning, ref)
}

func dubiousMultipleHostsMsg(count int, hosts string) errors.Error {
return errors.New(errors.CompositeErrorCode, DubiousMultipleHostsWarning, count, hosts)
}
209 changes: 209 additions & 0 deletions spec_ref_warnings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package validate

import (
"net/url"
"path"
"sort"
"strings"

"github.com/go-openapi/spec"
)

// minDistinctHostsToWarn is the number of distinct remote hosts among $refs at or above which
// Rule 2 emits a host-spread warning. A single consistent remote host is legitimate.
const minDistinctHostsToWarn = 2

// validateDubiousRefs emits warnings (never errors) when $ref locations match patterns
// that may indicate an unsafe or adversarial spec. It inspects refs as authored, on the
// UNEXPANDED spec, so it must run before expansion flattens them away.
//
// Two rules are applied over s.analyzer.AllRefs():
//
// - Rule 1 (absolute local escape): a $ref pointing to an absolute local file location
// (file:// scheme, a Unix absolute path, or a Windows drive path such as C:\) is dubious
// UNLESS it stays beneath the spec's base path. Absolute refs beneath the base are
// legitimate: flattening/expansion in go-openapi/spec and analysis introduces absolute
// anchors to resolve cyclical $refs. Relative and fragment-only refs are always exempt.
//
// - Rule 2 (host spread): when remote (http/https, or protocol-relative) refs resolve to
// two or more distinct hosts, a single aggregate warning lists them. A single consistent
// remote host is common and legitimate, so it is not flagged.
//
// All findings are warnings: they do not affect validity (see Result.IsValid).
func (s *SpecValidator) validateDubiousRefs() *Result {
res := pools.poolOfResults.BorrowResult()

baseDir, hasBase := s.localBaseDir()

remoteHosts := make(map[string]struct{})
for _, r := range s.analyzer.AllRefs() {
u := r.GetURL()
if u == nil { // Safeguard: a valid spec always yields parseable refs
continue
}

// Rule 1: absolute local reference escaping the base path.
if refPath, isLocalAbs := absoluteLocalRefPath(r, u); isLocalAbs {
if !hasBase || !isBeneathBase(refPath, baseDir) {
res.AddWarnings(dubiousAbsoluteRefMsg(r.String()))
}
continue
}

// Rule 2: gather remote hosts (http/https and protocol-relative //host/...).
if host := remoteRefHost(u); host != "" {
remoteHosts[host] = struct{}{}
}
}

if len(remoteHosts) >= minDistinctHostsToWarn {
hosts := make([]string, 0, len(remoteHosts))
for h := range remoteHosts {
hosts = append(hosts, h)
}
sort.Strings(hosts)
res.AddWarnings(dubiousMultipleHostsMsg(len(hosts), strings.Join(hosts, ", ")))
}

return res
}

// absoluteLocalRefPath reports whether r is an absolute LOCAL file reference and, if so,
// returns the cleaned path it points to (without scheme/fragment, drive letter lower-cased).
//
// Classification order matters (see the empirical jsonreference flag behavior):
// - file:// scheme is local, including UNC file://host/share (inherently dubious).
// - a non-empty Host with no file scheme means remote (http/https or protocol-relative
// //host/path) - NOT local; handled by Rule 2. This must be checked before the Unix
// branch, because protocol-relative refs also set HasFullFilePath.
// - len(u.Scheme) == 1 is a Windows drive path (C:\ or C:/), whose drive+path land in
// Scheme/Opaque/Path rather than Path. Checked before the Unix branch because C:/x also
// sets HasFullFilePath, and reconstructed from the authored ref string to keep the drive.
// - !r.HasFullURL && r.HasFullFilePath is a plain Unix absolute path (/abs/models.json).
//
// Relative (./x.json) and fragment-only (#/definitions/X) refs return false.
func absoluteLocalRefPath(r spec.Ref, u *url.URL) (string, bool) {
switch {
case r.HasFileScheme:
return fileRefPath(u), true
case u.Host != "":
// Remote (http/https) or protocol-relative //host/path: handled by Rule 2.
return "", false
case len(u.Scheme) == 1:
// Windows drive letter: reconstruct from the authored ref string.
return cleanRefPath(r.String()), true
case !r.HasFullURL && r.HasFullFilePath:
return cleanRefPath(u.Path), true
default:
return "", false
}
}

// remoteRefHost returns the host of a remote reference (http/https), or of a protocol-relative
// reference (//host/path). It returns "" for local and fragment-only refs. file:// hosts (UNC)
// are deliberately excluded: those are handled as local-absolute refs by Rule 1.
func remoteRefHost(u *url.URL) string {
switch u.Scheme {
case "http", "https":
return u.Host
case "":
// Protocol-relative //host/path: empty scheme but a host is present.
return u.Host
default:
return ""
}
}

// localBaseDir returns the directory of the spec file, slash-normalized, when the spec was
// loaded from a local path. It returns ok=false when the base is unknown (in-memory spec) or
// remote (http/https), in which case absolute-local refs cannot be proven beneath a base and
// are treated as dubious.
func (s *SpecValidator) localBaseDir() (string, bool) {
specPath := s.spec.SpecFilePath()
if specPath == "" {
return "", false
}

// Strip a file:// scheme if present; reject remote bases.
if u, err := url.Parse(specPath); err == nil && u.Scheme != "" {
switch {
case u.Scheme == "file":
specPath = u.Path
case len(u.Scheme) == 1: // Windows drive letter, treat as local
// keep specPath as-is (authored path)
default: // http, https, ... : no local base
return "", false
}
}

return path.Dir(cleanRefPath(specPath)), true
}

// isBeneathBase reports whether the cleaned target path is located within baseDir, i.e. it does
// not escape baseDir via "..". Comparison is purely lexical on cleaned paths, which is sufficient
// (and cross-platform safe) for a non-fatal warning. Both sides are expected to already be
// cleanRefPath-normalized (slashes, drive-letter case).
func isBeneathBase(target, baseDir string) bool {
if baseDir == "" {
return false
}
if target == baseDir {
return true
}
if !strings.HasSuffix(baseDir, "/") {
baseDir += "/"
}
return strings.HasPrefix(target, baseDir)
}

// fileRefPath extracts the local path a file:// reference points to, accounting for the way
// Windows file URLs parse:
// - file:///abs/x -> /abs/x (empty host)
// - file:///C:/dir/x -> /c:/dir/x (empty host; drive sits in the path)
// - file://D:/a/x -> d:/a/x (drive letter lands in Host, rejoin it)
// - file://host/share -> /host/share/x (real UNC host kept visible so it cannot match a
// local base and stays flagged as dubious)
func fileRefPath(u *url.URL) string {
switch {
case u.Host == "":
return cleanRefPath(u.Path)
case isDriveHost(u.Host):
// Windows path authored as file://D:/... : the drive landed in Host (e.g. "d:").
return cleanRefPath(u.Host + u.Path)
default:
// Real remote/UNC host: keep it in the path so it never matches a local base.
return cleanRefPath("//" + u.Host + u.Path)
}
}

// isDriveHost reports whether a URL host is actually a Windows drive letter (e.g. "d:"), which
// happens when a Windows path is authored as a two-slash file URL: file://D:/path.
func isDriveHost(host string) bool {
h := strings.TrimSuffix(host, ":")
if len(h) != 1 {
return false
}
c := h[0]
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}

// cleanRefPath normalizes a ref or base path for lexical comparison: backslashes to forward
// slashes, path.Clean, and a lower-cased leading Windows drive letter (matching the behavior of
// go-openapi/spec's normalizer). Plain Unix paths are unaffected, preserving case-sensitivity.
func cleanRefPath(p string) string {
p = path.Clean(strings.ReplaceAll(p, `\`, `/`))
switch {
case len(p) >= 2 && p[1] == ':':
// drive-letter form: C:/dir -> c:/dir
p = strings.ToLower(p[:1]) + p[1:]
case len(p) >= 3 && p[0] == '/' && p[2] == ':':
// slash-prefixed drive form from canonical file:// URLs: /C:/dir -> c:/dir.
// The leading slash is dropped so this matches the base path derived from
// SpecFilePath (which has no leading slash), and the bare-drive form.
p = strings.ToLower(p[1:2]) + p[2:]
}
return p
}
Loading
Loading