vendor: update buildkit to v0.32.0-rc1

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>
This commit is contained in:
Tonis Tiigi
2026-07-22 15:27:47 -07:00
parent efd9aa1dea
commit 0cf7592d41
464 changed files with 19941 additions and 15772 deletions
-1
View File
@@ -3,5 +3,4 @@
.idea
.env
.mcp.json
.claude/
settings.local.json
+3 -3
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 13 | 110 |
| 13 | 115 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 58 | <https://github.com/go-openapi/errors/commits?author=casualjim> |
| @fredbi | 36 | <https://github.com/go-openapi/errors/commits?author=fredbi> |
| @fredbi | 41 | <https://github.com/go-openapi/errors/commits?author=fredbi> |
| @youyuanwu | 5 | <https://github.com/go-openapi/errors/commits?author=youyuanwu> |
| @alexandear | 2 | <https://github.com/go-openapi/errors/commits?author=alexandear> |
| @fiorix | 1 | <https://github.com/go-openapi/errors/commits?author=fiorix> |
@@ -22,4 +22,4 @@
| @aokumasan | 1 | <https://github.com/go-openapi/errors/commits?author=aokumasan> |
| @ujjwalsh | 1 | <https://github.com/go-openapi/errors/commits?author=ujjwalsh> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+1 -1
View File
@@ -106,7 +106,7 @@ Maintainers can cut a new release by either:
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
+1
View File
@@ -3,3 +3,4 @@
.idea
.env
.mcp.json
.worktrees
+2
View File
@@ -7,6 +7,8 @@ linters:
- gochecknoglobals # on this repo, it is hard to refactor without globals/inits and no breaking change
- gochecknoinits
- godox
- gomodguard
- gomodguard_v2
- exhaustruct
- nlreturn
- nonamedreturns
+3 -3
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 14 | 123 |
| 14 | 133 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 55 | <https://github.com/go-openapi/loads/commits?author=fredbi> |
| @casualjim | 48 | <https://github.com/go-openapi/loads/commits?author=casualjim> |
| @fredbi | 45 | <https://github.com/go-openapi/loads/commits?author=fredbi> |
| @youyuanwu | 6 | <https://github.com/go-openapi/loads/commits?author=youyuanwu> |
| @vburenin | 4 | <https://github.com/go-openapi/loads/commits?author=vburenin> |
| @keramix | 4 | <https://github.com/go-openapi/loads/commits?author=keramix> |
@@ -23,4 +23,4 @@
| @kreativka | 1 | <https://github.com/go-openapi/loads/commits?author=kreativka> |
| @petrkotas | 1 | <https://github.com/go-openapi/loads/commits?author=petrkotas> |
_this file was generated by the [Contributors GitHub Action](https://github.com/github/contributors)_
_this file was generated by the [Contributors GitHub Action](https://github.com/github-community-projects/contributors)_
+45 -10
View File
@@ -20,12 +20,9 @@ Supports JSON and YAML documents.
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
## Status
API is stable.
@@ -58,6 +55,41 @@ go get github.com/go-openapi/loads
See also the provided [examples](https://pkg.go.dev/github.com/go-openapi/loads#pkg-examples).
## Security
This library does not enforce a security policy of its own: it reads whatever the configured
loader is allowed to read.
This is deliberate — like `go-openapi/swag/loading`, it is a base utility,
and sanitizing or containing untrusted input is the caller's responsibility,
just as sanitizing a file name before passing it to `os.ReadFile` is not that function's job.
When a spec — its path or its `$ref` contents — may come from an untrusted source, confine
loading explicitly (e.g. `loading.WithRoot` for local files and a restricted
`loading.WithHTTPClient` for remote URLs, passed via `loads.WithLoadingOptions`).
For the common case, the pre-baked `loads.SpecRestricted` / `loads.JSONSpecRestricted` loaders
bundle a trusted root with a network-restricted client (`loads.RestrictedHTTPClient`) and apply
the confinement to `$ref` resolution as well:
```go
doc, err := loads.SpecRestricted(path, trustedRoot)
```
To harden the package-level default in one call — so even callers that rely on the global
loader (including cross-package `$ref` resolution via `spec.PathLoader`) are confined, with no
unconfined fallback left — use `loads.SetRestrictedLoaders` at startup:
```go
loads.SetRestrictedLoaders(trustedRoot)
```
Note that `loads.AddLoader` only *prepends* to the default chain, leaving the unconfined loader
reachable; use `loads.SetLoaders` / `loads.SetRestrictedLoaders` to replace it.
See the [Security section of the package documentation][security-doc] for the threat model and
runnable examples. For the project's vulnerability reporting policy, see [SECURITY.md](./SECURITY.md).
## Change log
See <https://github.com/go-openapi/loads/releases>
@@ -69,9 +101,9 @@ This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE).
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -102,11 +134,8 @@ Maintainers can cut a new release by either:
<!-- Badges: documentation & support -->
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/loads
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/loads
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/twZ9BwT3
[discord-url]: https://discord.gg/FfnFYaC3k5
<!-- Badges: license & compliance -->
[license-badge]: http://img.shields.io/badge/license-Apache%20v2-orange.svg
@@ -116,3 +145,9 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/loads/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/loads
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/loads/latest
<!-- Documentation links -->
[security-doc]: https://pkg.go.dev/github.com/go-openapi/loads#hdr-Security
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+68
View File
@@ -6,4 +6,72 @@
// It is used by other go-openapi packages to load and run analysis on local or remote spec documents.
//
// Loaders support JSON and YAML documents.
//
// # Security
//
// This package does not enforce a security policy of its own: like the underlying
// [github.com/go-openapi/swag/loading] utilities, it reads whatever the configured loader is
// allowed to read.
//
// When a spec — its path or its contents — may derive from untrusted input, the caller must confine loading explicitly.
//
// This is a deliberate design choice.
// Both this package and the [github.com/go-openapi/swag/loading] utilities are base building blocks:
// deciding which sources are legitimate, and containing access to them,
// requires application context that a general-purpose loader does not have.
//
// Just as sanitizing a file name before handing it to [os.ReadFile] is the caller's
// responsibility and not that function's, sanitizing and containing the path and references
// resolved here is the responsibility of the code that may feed them untrusted input.
//
// There are two distinct attack surfaces:
//
// - The path passed to [Spec], [JSONSpec], or [Embedded]. By default a local path is read
// with no confinement, so a caller-controlled path (including an absolute path or a
// "file:///etc/passwd" URI) may read any file the process can access. A remote path is
// fetched with [net/http.DefaultClient], which follows redirects and performs no
// destination filtering, so a caller-controlled URL may reach internal services or cloud
// metadata endpoints (server-side request forgery).
//
// - The contents of the spec, when references are resolved. [Document.Expanded] follows the
// "$ref" pointers found inside the document by calling the same loader recursively. A spec
// obtained even from a trusted path can therefore drive arbitrary local reads
// ("$ref": "file:///etc/passwd") or SSRF ("$ref": "http://169.254.169.254/...") through
// its own contents. This amplification is specific to reference resolution and does not
// exist in the raw loading utilities.
//
// Mitigation. Pass [github.com/go-openapi/swag/loading] options through [WithLoadingOptions];
// they are attached to the document's loader and so apply both to the initial load and to
// every "$ref" resolved during expansion:
//
// - [github.com/go-openapi/swag/loading.WithRoot] confines local reads to a trusted
// directory, rejecting absolute paths, ".." traversal, and symlinks that escape it. Prefer
// it over a [github.com/go-openapi/swag/loading.WithFS] built from [os.DirFS], which does
// not block symlink escapes.
//
// - [github.com/go-openapi/swag/loading.WithHTTPClient] allows to supply a restricted HTTP client.
// Enforce the network policy at dial time (a [net.Dialer] Control hook), so it also covers
// redirects and DNS rebinding, which a URL-string allowlist cannot. See the example on
// [Spec].
//
// Pre-baked loaders. When the opinionated defaults fit, [SpecRestricted], [JSONSpecRestricted]
// and [JSONDocRestricted] bundle a trusted root with a network-restricted client
// ([RestrictedHTTPClient]), and apply the confinement to "$ref" resolution as well — so the
// common case needs no manual wiring. To harden the global default in one call (so even callers
// that rely on the package-level loader are confined), use [SetRestrictedLoaders]. Reach for the
// options above when you need a custom policy; [IsForbiddenAddress] exposes the default network
// policy so you can reuse it as the base of your own HTTP client.
//
// Caveats:
//
// - The package-level default loader (also installed as [github.com/go-openapi/spec.PathLoader])
// carries no loading options and is therefore unconfined. It is used as a fallback when
// expansion runs without a document loader, and by other go-openapi packages that resolve
// references on their own. [AddLoader] does not fix this — it only prepends, leaving the
// unconfined fallback reachable. Either build a confined loader per call, or replace the
// global default outright with [SetLoaders] / [SetRestrictedLoaders].
//
// - A custom loader installed via [WithDocLoader] or [AddLoader] only honors these
// protections if its loading function actually applies the [github.com/go-openapi/swag/loading]
// options it is given.
package loads
+4
View File
@@ -15,4 +15,8 @@ const (
// ErrNoLoader indicates that no configured loader matched the input.
ErrNoLoader loaderError = "no loader matched"
// ErrForbiddenAddress is returned by [RestrictedHTTPClient] when a connection is attempted
// to a non-public address (loopback, private, link-local, or unspecified).
ErrForbiddenAddress loaderError = "blocked dial to a non-public address"
)
+76 -3
View File
@@ -21,6 +21,15 @@ import (
var loaders *loader
func init() {
loaders = defaultLoaders()
// sets the global default loader for go-openapi/spec
spec.PathLoader = loaders.Load
}
// defaultLoaders builds the built-in loader chain: a YAML matcher first, with a JSON loader as
// the catch-all fallback.
func defaultLoaders() *loader {
jsonLoader := &loader{
DocLoaderWithMatch: DocLoaderWithMatch{
Match: func(_ string) bool {
@@ -30,15 +39,35 @@ func init() {
},
}
loaders = jsonLoader.WithHead(&loader{
return jsonLoader.WithHead(&loader{
DocLoaderWithMatch: DocLoaderWithMatch{
Match: loading.YAMLMatcher,
Fn: loading.YAMLDoc,
},
})
}
// sets the global default loader for go-openapi/spec
spec.PathLoader = loaders.Load
// buildLoaderChain links a list of [DocLoaderWithMatch] into a loader chain, preserving order.
// Entries with a nil Fn are skipped. Returns nil when no usable loader is provided.
func buildLoaderChain(ldrs ...DocLoaderWithMatch) *loader {
var final, prev *loader
for _, ldr := range ldrs {
if ldr.Fn == nil {
continue
}
node := &loader{DocLoaderWithMatch: ldr}
if prev == nil {
final = node
prev = node
continue
}
prev = prev.WithNext(node)
}
return final
}
// DocLoader represents a doc loader type.
@@ -141,6 +170,17 @@ func JSONDoc(path string, opts ...loading.Option) (json.RawMessage, error) {
//
// This function updates the default loader used by [github.com/go-openapi/spec].
// Since this sets package level globals, you shouldn't call this concurrently.
//
// # Security
//
// AddLoader only *prepends* to the default chain: the previous loaders — including the
// unconfined JSON fallback — remain reachable, both here and via cross-package "$ref"
// resolution. It is therefore the wrong tool for hardening the global default. To replace the
// chain entirely (leaving no unconfined fallback) use [SetLoaders], or [SetRestrictedLoaders]
// for a one-call confined setup. For a single load, prefer a confined per-call loader via
// [WithLoadingOptions] or [WithDocLoaderMatches]. A custom loader registered here only honors
// the protections if its loading function applies the [github.com/go-openapi/swag/loading]
// options it is given. See the package documentation on Security.
func AddLoader(predicate DocMatcher, load DocLoader) {
loaders = loaders.WithHead(&loader{
DocLoaderWithMatch: DocLoaderWithMatch{
@@ -152,3 +192,36 @@ func AddLoader(predicate DocMatcher, load DocLoader) {
// sets the global default loader for go-openapi/spec
spec.PathLoader = loaders.Load
}
// SetLoaders replaces the package-level default loader chain with the given loaders, tried in
// order, and re-points [github.com/go-openapi/spec.PathLoader] at it.
//
// Unlike [AddLoader], nothing of the previous default survives — so when the replacement is
// confined, no unconfined fallback remains for any caller relying on the global default
// (including cross-package "$ref" resolution). An entry with a nil Match is a catch-all; you
// are responsible for providing a suitable fallback. Calling SetLoaders with no usable loader
// restores the built-in default (a YAML matcher with a JSON fallback).
//
// # Concurrency
//
// This sets package-level globals and the [github.com/go-openapi/spec] global loader. It is
// not safe to call concurrently with other loads or with [AddLoader]; configure it once at
// startup, before serving.
//
// # Security
//
// This is the way to harden the global default in one place. For a ready-made confined setup,
// see [SetRestrictedLoaders]. As with [AddLoader], a custom loader only honors the protections
// if its loading function applies the [github.com/go-openapi/swag/loading] options it is given.
// See the package documentation on Security.
func SetLoaders(ldrs ...DocLoaderWithMatch) {
chain := buildLoaderChain(ldrs...)
if chain == nil {
chain = defaultLoaders()
}
loaders = chain
// sets the global default loader for go-openapi/spec
spec.PathLoader = loaders.Load
}
+6 -15
View File
@@ -51,25 +51,16 @@ func WithDocLoader(l DocLoader) LoaderOption {
// Loaders are executed in the order of provided [DocLoaderWithMatch] 'es.
func WithDocLoaderMatches(l ...DocLoaderWithMatch) LoaderOption {
return func(opt *options) {
var final, prev *loader
for _, ldr := range l {
if ldr.Fn == nil {
continue
}
if prev == nil {
final = &loader{DocLoaderWithMatch: ldr}
prev = final
continue
}
prev = prev.WithNext(&loader{DocLoaderWithMatch: ldr})
}
opt.loader = final
opt.loader = buildLoaderChain(l...)
}
}
// WithLoadingOptions adds some [loading.Option] to be added when calling a registered loader.
//
// The options are attached to the document's loader, so they apply both to the initial load
// and to every "$ref" resolved during [Document.Expanded]. This is the recommended place to
// confine loading of untrusted input, for example with [loading.WithRoot] (local) and
// [loading.WithHTTPClient] (remote). See the package documentation on Security.
func WithLoadingOptions(loadingOptions ...loading.Option) LoaderOption {
return func(opt *options) {
opt.loadingOptions = loadingOptions
+185
View File
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0
package loads
import (
"encoding/json"
"net"
"net/http"
"net/netip"
"syscall"
"time"
"github.com/go-openapi/swag/loading"
)
const (
// numConfinementOptions is the count of loading options appended to enforce confinement
// (WithRoot + WithHTTPClient), used to size the bundled option slice.
numConfinementOptions = 2
defaultTLSHandshakeTimeout = 10 * time.Second
)
// RestrictedHTTPClient returns an [http.Client] that refuses, at dial time, to connect to
// loopback, private, link-local (including cloud-metadata endpoints such as 169.254.169.254),
// or unspecified addresses. A blocked connection fails with an error wrapping
// [ErrForbiddenAddress].
//
// The check runs in the dialer Control hook, after DNS resolution and before connect, so it
// also covers HTTP redirects and DNS rebinding — which a URL-string allowlist cannot. The
// client does not honor proxy environment variables, so the guard always inspects the real
// destination rather than a proxy address.
//
// This is the network half of the restricted loaders ([JSONDocRestricted],
// [JSONSpecRestricted], [SpecRestricted]). It may also be used directly with
// [github.com/go-openapi/swag/loading.WithHTTPClient].
//
// The policy is opinionated and deliberately simple. For a different one (a custom allow/deny
// list, an explicit proxy, mutual TLS, ...), build your own client and pass it with
// [github.com/go-openapi/swag/loading.WithHTTPClient]. To keep the default address policy as a
// base, reuse [IsForbiddenAddress] in your own dialer Control hook — see the package examples
// for the pattern.
func RestrictedHTTPClient() *http.Client {
control := func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
addr, err := netip.ParseAddr(host)
if err != nil {
return err
}
if IsForbiddenAddress(addr) {
return ErrForbiddenAddress
}
return nil
}
return &http.Client{
Transport: &http.Transport{
Proxy: nil, // dial the real destination so the guard inspects it
DialContext: (&net.Dialer{Control: control}).DialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: defaultTLSHandshakeTimeout,
},
}
}
// IsForbiddenAddress reports whether addr is one that [RestrictedHTTPClient] refuses to dial:
// a loopback, private, link-local (including cloud-metadata endpoints such as 169.254.169.254),
// or unspecified address. IPv4-mapped IPv6 addresses are unmapped before the check.
//
// It is exported so callers can reuse or extend the default policy when building their own
// dialer Control hook, for example to also reject a CGNAT range or to carve out a single
// trusted internal host:
//
// control := func(_, address string, _ syscall.RawConn) error {
// host, _, err := net.SplitHostPort(address)
// if err != nil {
// return err
// }
// addr, err := netip.ParseAddr(host)
// if err != nil {
// return err
// }
// if loads.IsForbiddenAddress(addr) && host != allowedInternalHost {
// return loads.ErrForbiddenAddress
// }
// return nil
// }
func IsForbiddenAddress(addr netip.Addr) bool {
a := addr.Unmap()
return a.IsLoopback() || a.IsPrivate() || a.IsLinkLocalUnicast() || a.IsUnspecified()
}
// restrictedLoadingOptions bundles caller-supplied options with the confinement options,
// appended last so that local rooting and the restricted client always take precedence
// (the loading options are last-wins).
func restrictedLoadingOptions(root string, extra []loading.Option) []loading.Option {
out := make([]loading.Option, 0, len(extra)+numConfinementOptions)
out = append(out, extra...)
out = append(out, loading.WithRoot(root), loading.WithHTTPClient(RestrictedHTTPClient()))
return out
}
// JSONDocRestricted returns a JSON [DocLoader] that confines local reads to root (via
// [github.com/go-openapi/swag/loading.WithRoot]) and restricts remote fetches with
// [RestrictedHTTPClient].
//
// The returned loader may be registered with [WithDocLoader] or [AddLoader]. The confinement
// always takes precedence over any option passed here or at call time, so a caller cannot
// loosen it through [WithLoadingOptions].
//
// Like [JSONDoc], it loads JSON only: it does not convert YAML. For specs whose references may
// point at YAML documents, prefer [SpecRestricted], which keeps the default JSON/YAML chain.
func JSONDocRestricted(root string, opts ...loading.Option) DocLoader {
// one restricted client, reused for every path and $ref
return restrictedDocLoader(JSONDoc, restrictedLoadingOptions(root, opts))
}
// restrictedDocLoader wraps a [DocLoader] so that the confinement options in base are always
// applied, appended after any call-time options so they take precedence (loading options are
// last-wins).
func restrictedDocLoader(fn DocLoader, base []loading.Option) DocLoader {
return func(path string, callOpts ...loading.Option) (json.RawMessage, error) {
if len(callOpts) == 0 {
return fn(path, base...)
}
all := make([]loading.Option, 0, len(callOpts)+len(base))
all = append(all, callOpts...)
all = append(all, base...) // confinement (tail of base) still wins
return fn(path, all...)
}
}
// JSONSpecRestricted loads a JSON spec like [JSONSpec], but confines local reads to root and
// restricts remote fetches with [RestrictedHTTPClient].
//
// The confinement is attached to the document's loader, so it also applies to every "$ref"
// resolved by [Document.Expanded]. Extra [github.com/go-openapi/swag/loading] options (custom
// headers, basic auth, timeout, ...) may be supplied; the confinement always wins over them.
func JSONSpecRestricted(path, root string, opts ...loading.Option) (*Document, error) {
return JSONSpec(path, WithLoadingOptions(restrictedLoadingOptions(root, opts)...))
}
// SpecRestricted loads a spec like [Spec] — with JSON/YAML auto-detection — but confines local
// reads to root and restricts remote fetches with [RestrictedHTTPClient].
//
// The confinement is attached to the document's loader, so it also applies to every "$ref"
// resolved by [Document.Expanded]. Extra [github.com/go-openapi/swag/loading] options (custom
// headers, basic auth, timeout, ...) may be supplied; the confinement always wins over them.
func SpecRestricted(path, root string, opts ...loading.Option) (*Document, error) {
return Spec(path, WithLoadingOptions(restrictedLoadingOptions(root, opts)...))
}
// SetRestrictedLoaders hardens the package-level default in a single call: it installs a
// confined JSON/YAML loader chain — local reads rooted at root, remote fetches through
// [RestrictedHTTPClient] — as the global default and as
// [github.com/go-openapi/spec.PathLoader].
//
// After this call, every load that relies on the package default ([Spec], [JSONSpec], and any
// cross-package "$ref" resolution) is confined, with no unconfined fallback left behind. It is
// the global counterpart of [SpecRestricted]; a single restricted client is shared across the
// chain. Extra [github.com/go-openapi/swag/loading] options may be supplied; the confinement
// always wins over them.
//
// # Concurrency
//
// Like [SetLoaders], this mutates package-level and [github.com/go-openapi/spec] globals and is
// not safe to call concurrently. Configure it once at startup, before serving. To revert, call
// [SetLoaders] with no arguments.
func SetRestrictedLoaders(root string, opts ...loading.Option) {
base := restrictedLoadingOptions(root, opts) // one restricted client shared by the whole chain
SetLoaders(
NewDocLoaderWithMatch(restrictedDocLoader(loading.YAMLDoc, base), loading.YAMLMatcher),
NewDocLoaderWithMatch(restrictedDocLoader(JSONDoc, base), nil), // nil matcher: JSON catch-all fallback
)
}
+16
View File
@@ -77,6 +77,14 @@ func Embedded(orig, flat json.RawMessage, opts ...LoaderOption) (*Document, erro
// Spec loads a new spec document from a local or remote path.
//
// By default it uses a JSON or YAML loader, with auto-detection based on the resource extension.
//
// Security: by default the path is read with no confinement (local) and fetched with
// [net/http.DefaultClient] (remote), and any "$ref" later resolved by [Document.Expanded] is
// loaded the same way. When the path or the spec contents may derive from untrusted input,
// confine loading with [WithLoadingOptions] (for example
// [github.com/go-openapi/swag/loading.WithRoot] and
// [github.com/go-openapi/swag/loading.WithHTTPClient]). See the package documentation on
// Security.
func Spec(path string, opts ...LoaderOption) (*Document, error) {
ldr := loaderFromOptions(opts)
@@ -157,6 +165,14 @@ func trimData(in json.RawMessage) (json.RawMessage, error) {
}
// Expanded expands the $ref fields in the spec [Document] and returns a new expanded [Document].
//
// Security: expansion resolves every "$ref" by calling the document's loader recursively, so
// the spec contents drive further loads. A spec from an untrusted source can thus trigger
// arbitrary local reads or SSRF through its references. The loader carries the
// [github.com/go-openapi/swag/loading] options supplied via [WithLoadingOptions] at load time;
// configure confinement there so it applies to expansion as well. When no document loader is
// set, expansion falls back to the unconfined package-level loader. See the package
// documentation on Security.
func (d *Document) Expanded(options ...*spec.ExpandOptions) (*Document, error) {
swspec := new(spec.Swagger)
if err := json.Unmarshal(d.raw, swspec); err != nil {
+1 -1
View File
@@ -36,7 +36,7 @@ linters:
dupl:
threshold: 200
goconst:
min-len: 2
min-len: 9
min-occurrences: 3
cyclop:
max-complexity: 25
+2 -2
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 71 | 565 |
| 71 | 567 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 268 | <https://github.com/go-openapi/runtime/commits?author=casualjim> |
| @fredbi | 140 | <https://github.com/go-openapi/runtime/commits?author=fredbi> |
| @fredbi | 142 | <https://github.com/go-openapi/runtime/commits?author=fredbi> |
| @youyuanwu | 19 | <https://github.com/go-openapi/runtime/commits?author=youyuanwu> |
| @josephwoodward | 13 | <https://github.com/go-openapi/runtime/commits?author=josephwoodward> |
| @kenjones-cisco | 12 | <https://github.com/go-openapi/runtime/commits?author=kenjones-cisco> |
+1 -1
View File
@@ -73,7 +73,7 @@ func (o *UntypedRequestBinder) bind(request *http.Request, routeParams RoutePara
if isMap {
tpe := binder.Type()
if tpe == nil {
if param.Schema.Type.Contains(typeArray) {
if param.Schema != nil && param.Schema.Type.Contains(typeArray) {
tpe = reflect.TypeFor[[]any]()
} else {
tpe = reflect.TypeFor[map[string]any]()
+3
View File
@@ -4,7 +4,10 @@ linters:
disable:
- depguard
- funlen
- goconst
- godox
- gomodguard
- gomodguard_v2
- exhaustruct
- nlreturn
- nonamedreturns
+2 -2
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 38 | 396 |
| 38 | 398 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 191 | <https://github.com/go-openapi/spec/commits?author=casualjim> |
| @fredbi | 94 | <https://github.com/go-openapi/spec/commits?author=fredbi> |
| @fredbi | 96 | <https://github.com/go-openapi/spec/commits?author=fredbi> |
| @pytlesk4 | 26 | <https://github.com/go-openapi/spec/commits?author=pytlesk4> |
| @kul-amr | 10 | <https://github.com/go-openapi/spec/commits?author=kul-amr> |
| @keramix | 10 | <https://github.com/go-openapi/spec/commits?author=keramix> |
+7 -9
View File
@@ -18,12 +18,9 @@ The object model for OpenAPI v2 specification documents.
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
## Status
API is stable.
@@ -95,9 +92,9 @@ This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE).
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -132,9 +129,6 @@ Maintainers can cut a new release by either:
[doc-url]: https://goswagger.io/go-openapi
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/spec
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/spec
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/FfnFYaC3k5
@@ -146,3 +140,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/spec/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/spec
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/spec/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+5 -1
View File
@@ -150,7 +150,11 @@ func (h Header) MarshalJSON() ([]byte, error) {
if err != nil {
return nil, err
}
return jsonutils.ConcatJSON(b1, b2, b3), nil
b4, err := json.Marshal(h.VendorExtensible)
if err != nil {
return nil, err
}
return jsonutils.ConcatJSON(b1, b2, b3, b4), nil
}
// UnmarshalJSON unmarshals this header from JSON.
+1 -1
View File
@@ -117,7 +117,7 @@ func (r *schemaLoader) updateBasePath(transitive *schemaLoader, basePath string)
func (r *schemaLoader) resolveRef(ref *Ref, target any, basePath string) error {
tgt := reflect.ValueOf(target)
if tgt.Kind() != reflect.Ptr {
if tgt.Kind() != reflect.Pointer {
return ErrResolveRefNeedsAPointer
}
+1
View File
@@ -2,6 +2,7 @@ version: "2"
linters:
default: all
disable:
- goconst # has become too noisy. Disabled
- depguard
- funlen
- gomoddirectives
+2 -2
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 40 | 234 |
| 40 | 239 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 88 | <https://github.com/go-openapi/strfmt/commits?author=casualjim> |
| @fredbi | 66 | <https://github.com/go-openapi/strfmt/commits?author=fredbi> |
| @fredbi | 71 | <https://github.com/go-openapi/strfmt/commits?author=fredbi> |
| @youyuanwu | 13 | <https://github.com/go-openapi/strfmt/commits?author=youyuanwu> |
| @jlambatl | 9 | <https://github.com/go-openapi/strfmt/commits?author=jlambatl> |
| @GlenDC | 5 | <https://github.com/go-openapi/strfmt/commits?author=GlenDC> |
+8
View File
@@ -1978,6 +1978,14 @@ func (r *Password) DeepCopy() *Password {
}
func isRequestURI(rawurl string) bool {
// url.ParseRequestURI assumes the input contains no "#fragment"
// (RFC 3986 §3.5). A URI with a fragment and an empty path, such as
// "https://host#@frag", is therefore misread as userinfo and rejected
// as "invalid userinfo". Strip the fragment first so the absolute
// request URI validates, matching url.Parse's RFC 3986 handling.
if i := strings.IndexByte(rawurl, '#'); i >= 0 {
rawurl = rawurl[:i]
}
_, err := url.ParseRequestURI(rawurl)
return err == nil
}
+2 -2
View File
@@ -4,11 +4,11 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 24 | 242 |
| 24 | 246 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @fredbi | 112 | <https://github.com/go-openapi/swag/commits?author=fredbi> |
| @fredbi | 116 | <https://github.com/go-openapi/swag/commits?author=fredbi> |
| @casualjim | 98 | <https://github.com/go-openapi/swag/commits?author=casualjim> |
| @alexandear | 4 | <https://github.com/go-openapi/swag/commits?author=alexandear> |
| @orisano | 3 | <https://github.com/go-openapi/swag/commits?author=orisano> |
+7 -9
View File
@@ -34,12 +34,9 @@ You may also use it standalone for your projects.
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
## Status
API is stable.
@@ -171,9 +168,9 @@ on top of which it has been built.
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -208,9 +205,6 @@ Maintainers can cut a new release by either:
[doc-url]: https://goswagger.io/go-openapi
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/swag
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/swag
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/FfnFYaC3k5
@@ -222,3 +216,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/swag/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/swag
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/swag/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+24
View File
@@ -2,4 +2,28 @@
// SPDX-License-Identifier: Apache-2.0
// Package loading provides tools to load a file from http or from a local file system.
//
// # Security
//
// By default, the local loader reads any path the process can access, including absolute
// paths and "file://" URIs (for example "file:///etc/passwd"). Applications that pass
// untrusted input to [LoadFromFileOrHTTP], [JSONDoc] (or to downstream consumers such as
// go-openapi/loads) must confine local loading to a trusted directory.
//
// Use [WithRoot] to do so: it resolves every requested path relative to a chosen directory
// and rejects anything that escapes it, including via symlink. It is built on [os.Root]
// and is therefore safer than passing an [os.DirFS] to [WithFS], which does not block
// symlink escapes.
//
// Remote loading uses a standard [net/http] client.
// By default it follows redirects and performs no destination filtering — exactly like [net/http.DefaultClient].
//
// A caller-controlled URL may therefore reach internal services or cloud metadata endpoints
// (server-side request forgery).
//
// This package does not, and should not, embed a network policy:
// when the URL may derive from untrusted input, supply a restricted client with
// [WithHTTPClient] whose transport rejects unwanted destinations at dial time — which also
// covers redirects and DNS rebinding.
// See the example on [LoadFromFileOrHTTP].
package loading
+32 -4
View File
@@ -17,7 +17,11 @@ import (
"strings"
)
// LoadFromFileOrHTTP loads the bytes from a file or a remote http server based on the path passed in
// LoadFromFileOrHTTP loads the bytes from a file or a remote http server based on the path passed in.
//
// Security: by default a local path is read with no confinement, so a caller-controlled path
// (including a "file://" URI or an absolute path) may read any file the process can access.
// When the path may derive from untrusted input, confine local loading with [WithRoot].
func LoadFromFileOrHTTP(pth string, opts ...Option) ([]byte, error) {
o := optionsWithDefaults(opts)
return LoadStrategy(pth, o.ReadFileFunc(), loadHTTPBytes(opts...), opts...)(pth)
@@ -54,11 +58,14 @@ func LoadFromFileOrHTTP(pth string, opts ...Option) ([]byte, error) {
// - `file:///c:/folder/file` becomes `C:\folder\file`
// - `file://c:/folder/file` is tolerated (without leading `/`) and becomes `c:\folder\file`
func LoadStrategy(pth string, local, remote func(string) ([]byte, error), opts ...Option) func(string) ([]byte, error) {
if strings.HasPrefix(pth, "http") {
if hasHTTPScheme(pth) {
return remote
}
o := optionsWithDefaults(opts)
_, isEmbedFS := o.fs.(embed.FS)
// any loader backed by an fs.FS or an os.Root consumes forward-slash paths on every
// platform, so it must not go through the windows-native file:// preprocessing below.
isFSBacked := o.fs != nil || o.root != ""
return func(p string) ([]byte, error) {
upth, err := url.PathUnescape(p)
@@ -67,14 +74,20 @@ func LoadStrategy(pth string, local, remote func(string) ([]byte, error), opts .
}
cpth, hasPrefix := strings.CutPrefix(upth, "file://")
if !hasPrefix || isEmbedFS || runtime.GOOS != "windows" {
if !hasPrefix || isFSBacked || runtime.GOOS != "windows" {
// crude processing: trim the file:// prefix. This leaves full URIs with a host with a (mostly) unexpected result
// regular file path provided: just normalize slashes
if isEmbedFS {
// on windows, we need to slash the path if FS is an embed FS.
// embed.FS always uses "/" as separator, even on windows, and rejects leading "./" or "/".
return local(strings.TrimLeft(filepath.ToSlash(cpth), "./")) // remove invalid leading characters for embed FS
}
if isFSBacked {
// other fs.FS (e.g. os.DirFS) and os.Root loaders also use "/" on every platform.
// Escaping paths (absolute, "..", escaping symlinks) are rejected by the loader, not rewritten here.
return local(filepath.ToSlash(cpth))
}
return local(filepath.FromSlash(cpth))
}
@@ -113,6 +126,21 @@ func LoadStrategy(pth string, local, remote func(string) ([]byte, error), opts .
}
}
// hasHTTPScheme reports whether pth is an absolute URL with an http or https scheme,
// selecting the remote loader. The comparison is case-insensitive, as URL schemes are.
//
// Requiring the "://" separator (rather than a bare "http" prefix) avoids misrouting a
// local file whose name merely starts with "http" (e.g. "httpbin.json") to the remote loader.
func hasHTTPScheme(pth string) bool {
for _, scheme := range [...]string{"http://", "https://"} {
if len(pth) >= len(scheme) && strings.EqualFold(pth[:len(scheme)], scheme) {
return true
}
}
return false
}
func loadHTTPBytes(opts ...Option) func(path string) ([]byte, error) {
o := optionsWithDefaults(opts)
+46 -1
View File
@@ -4,6 +4,7 @@
package loading
import (
"errors"
"io/fs"
"net/http"
"os"
@@ -23,7 +24,8 @@ type (
}
fileOptions struct {
fs fs.ReadFileFS
fs fs.ReadFileFS
root string // when non-empty, local reads are confined to this directory via os.Root
}
options struct {
@@ -33,6 +35,20 @@ type (
)
func (fo fileOptions) ReadFileFunc() func(string) ([]byte, error) {
if fo.root != "" {
root := fo.root
return func(name string) ([]byte, error) {
r, err := os.OpenRoot(root)
if err != nil {
return nil, errors.Join(err, ErrLoader)
}
defer func() { _ = r.Close() }()
return r.ReadFile(name)
}
}
if fo.fs == nil {
return os.ReadFile
}
@@ -87,8 +103,15 @@ func WithHTTPClient(client *http.Client) Option {
// By default, the file system is the one provided by the os package.
//
// For example, this may be set to consume from an embedded file system, or a rooted FS.
//
// WithFS and [WithRoot] are mutually exclusive: the last one applied wins.
//
// Security note: a file system built from [os.DirFS] confines paths but does NOT protect
// against symlinks that escape the root. To load from a directory derived from untrusted
// input, prefer [WithRoot], which is symlink-escape resistant.
func WithFS(filesystem fs.FS) Option {
return func(o *options) {
o.root = "" // last-wins vs WithRoot
if rfs, ok := filesystem.(fs.ReadFileFS); ok {
o.fs = rfs
@@ -98,6 +121,28 @@ func WithFS(filesystem fs.FS) Option {
}
}
// WithRoot confines local file loading to dir.
//
// Every requested path is resolved relative to dir, and any path that would escape dir —
// whether through an absolute path, ".." traversal, or a symlink pointing outside dir — is
// rejected. This is built on [os.Root] and is therefore resistant to the symlink escapes
// that a plain [os.DirFS] does not prevent.
//
// WithRoot is the recommended option when loading specs from a location derived from
// untrusted input. It applies to local loading only and has no effect on remote
// (http/https) loading. WithRoot and [WithFS] are mutually exclusive: the last one applied
// wins.
//
// Note: [os.Root] confines path resolution but does not, by itself, protect against
// traversal of mount/bind boundaries, /proc special files, or device files. Point WithRoot
// at a directory that holds only the documents you intend to expose.
func WithRoot(dir string) Option {
return func(o *options) {
o.root = dir
o.fs = nil // last-wins vs WithFS
}
}
type readFileFS struct {
fs.FS
}
+2 -2
View File
@@ -4,12 +4,12 @@
| Total Contributors | Total Contributions |
| --- | --- |
| 31 | 302 |
| 31 | 305 |
| Username | All Time Contribution Count | All Commits |
| --- | --- | --- |
| @casualjim | 169 | <https://github.com/go-openapi/validate/commits?author=casualjim> |
| @fredbi | 65 | <https://github.com/go-openapi/validate/commits?author=fredbi> |
| @fredbi | 68 | <https://github.com/go-openapi/validate/commits?author=fredbi> |
| @sttts | 11 | <https://github.com/go-openapi/validate/commits?author=sttts> |
| @youyuanwu | 9 | <https://github.com/go-openapi/validate/commits?author=youyuanwu> |
| @keramix | 8 | <https://github.com/go-openapi/validate/commits?author=keramix> |
+7 -9
View File
@@ -18,12 +18,9 @@ A validator for OpenAPI v2 specifications and JSON schema draft 4.
* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**
You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]
Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]
## Status
API is stable.
@@ -75,9 +72,9 @@ This library ships under the [SPDX-License-Identifier: Apache-2.0](./LICENSE).
## Other documentation
* [All-time contributors](./CONTRIBUTORS.md)
* [Contributing guidelines](.github/CONTRIBUTING.md)
* [Maintainers documentation](docs/MAINTAINERS.md)
* [Code style](docs/STYLE.md)
* [Contributing guidelines][contributing-doc-site]
* [Maintainers documentation][maintainers-doc-site]
* [Code style][style-doc-site]
## Cutting a new release
@@ -108,9 +105,6 @@ Maintainers can cut a new release by either:
<!-- Badges: documentation & support -->
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/validate
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/validate
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/FfnFYaC3k5
@@ -122,3 +116,7 @@ Maintainers can cut a new release by either:
[goversion-url]: https://github.com/go-openapi/validate/blob/master/go.mod
[top-badge]: https://img.shields.io/github/languages/top/go-openapi/validate
[commits-badge]: https://img.shields.io/github/commits-since/go-openapi/validate/latest
<!-- Organization docs -->
[contributing-doc-site]: https://go-openapi.github.io/doc-site/contributing/contributing/index.html
[maintainers-doc-site]: https://go-openapi.github.io/doc-site/maintainers/index.html
[style-doc-site]: https://go-openapi.github.io/doc-site/contributing/style/index.html
+7 -2
View File
@@ -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
}
@@ -553,8 +554,12 @@ DEFINITIONS:
if schema.Required != nil { // Safeguard
for _, pn := range schema.Required {
red := s.validateRequiredProperties(pn, d, &schema) //#nosec
// NOTE: capture validity before merging: Merge may redeem `red` to the
// pool (wantsRedeemOnMerge), after which reading it races with a concurrent
// BorrowResult().cleared() in another goroutine sharing the global pool.
isValid := red.IsValid()
res.Merge(red)
if !red.IsValid() && !s.Options.ContinueOnErrors {
if !isValid && !s.Options.ContinueOnErrors {
break DEFINITIONS // there is an error, let's stop that bleeding
}
}
+20
View File
@@ -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"
)
@@ -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
View File
@@ -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
}