Skip to content

Commit 5a88ba4

Browse files
fredbiclaude
andauthored
feat(runtime): BindForm helper for multipart/urlencoded body binding (#446)
Add a single orchestrator helper to the root runtime package that dedupes the parse-and-bind dance go-swagger codegen emits for every operation with form-data parameters: - BindForm(r, opts...) (fatal bool, err error) — parses the body (multipart/form-data, fallback to application/x-www-form-urlencoded) and runs per-file binders declared via BindFormFile options. - BindFormFile(name, required, FileBinder) — declares a file field. - BindFormMaxParseMemory, BindFormMaxBody, BindFormMaxFiles, BindFormMaxFilenameLen — security caps injectable via options. The (fatal, err) return preserves the codegen pattern of bailing on parse failure but accumulating per-file validation errors into a composite. Body cap defaults to 32 MiB via http.MaxBytesReader so the helper is bounded out of the box (413 on overflow); filename cap defaults to 1 KiB. All helper-produced errors are *errors.ParseError values built via errors.NewParseError, matching the untyped middleware/parameter.go path. Already-structured errors.Error values (e.g. 413 from a MaxBytesReader hit) pass through with their original code. Binder errors flow through verbatim — binders own their HTTP-aware shape. Pure addition to this module. The go-swagger codegen change consuming the helper is a separate PR in that repo. refactor(middleware): untyped formData binder uses runtime.BindForm Consolidate the multipart/urlencoded parse dance in middleware/parameter.go's "formData" case onto runtime.BindForm. The pre-flight content-type check (errors.InvalidContentType) and the per-file FormFile block stay open-coded; the manual ParseMultipartForm/ParseForm dance plus the err-wrap dance collapse into one helper call. Untyped error shapes are byte-identical to before; body is now capped at 32 MiB by default via http.MaxBytesReader (helper default). The helper also unifies errors reported by both the codegen use case and the untyped use case. Signed-off-by: Frederic BIDON <fredbi@yahoo.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7c678af commit 5a88ba4

4 files changed

Lines changed: 718 additions & 19 deletions

File tree

file.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,10 @@ package runtime
55

66
import "github.com/go-openapi/swag/fileutils"
77

8+
// File represents an uploaded file. Re-exported from
9+
// [fileutils.File] for backwards compatibility.
10+
//
11+
// See [BindForm] (in form.go) for the orchestrator that parses
12+
// multipart / urlencoded request bodies and binds declared file
13+
// fields onto handler-side targets.
814
type File = fileutils.File

form.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package runtime
5+
6+
import (
7+
stderrors "errors"
8+
"fmt"
9+
"mime/multipart"
10+
"net/http"
11+
12+
"github.com/go-openapi/errors"
13+
)
14+
15+
// DefaultMaxUploadFilenameLength is the default cap applied to
16+
// FileHeader.Filename for each declared file when BindForm is invoked
17+
// without an explicit [BindFormMaxFilenameLen] option.
18+
//
19+
// Multipart headers are allocated per part; an attacker submitting
20+
// multi-MB filenames inflates the parser's memory footprint. 1 KiB
21+
// matches the IETF guidance for sane filename length and is enough
22+
// for realistic uploads.
23+
const DefaultMaxUploadFilenameLength = 1024
24+
25+
// DefaultMaxUploadBodySize limits the size of the body to upload forms to 32MB.
26+
//
27+
// Use an explicit [BindFormMaxBody] option to change this limit.
28+
const DefaultMaxUploadBodySize = int64(32) << 20
29+
30+
// filenamePreviewLen caps the byte length of the FileHeader.Filename
31+
// preview embedded as the ParseError.Value field when the helper
32+
// rejects a too-long filename.
33+
const filenamePreviewLen = 32
34+
35+
// FileBinder is the per-file callback invoked by [BindForm] when a
36+
// declared file field is present.
37+
//
38+
// The callback is responsible for BOTH validating the file (size, MIME, etc.) AND assigning the bound
39+
// file to its destination — typically using:
40+
//
41+
// o.FieldName = &runtime.File{Data: file, Header: header}
42+
//
43+
// Returning a non-nil error surfaces the error in BindForm's per-field
44+
// accumulator. Errors from the binder flow through verbatim — the
45+
// binder is expected to produce HTTP-aware errors (e.g.
46+
// errors.ExceedsMaximum from go-openapi/validate).
47+
type FileBinder func(file multipart.File, header *multipart.FileHeader) error
48+
49+
// BindOption configures [BindForm]. The variadic style keeps simple
50+
// call sites simple and lets new knobs (security caps, additional
51+
// behaviour) be added without breaking the signature.
52+
type BindOption func(*bindConfig)
53+
54+
type bindConfig struct {
55+
maxParseMemory int64
56+
maxBody int64
57+
maxFiles int
58+
maxFilenameLen int
59+
files []formFileSpec
60+
}
61+
62+
type formFileSpec struct {
63+
name string
64+
required bool
65+
bind FileBinder
66+
}
67+
68+
// BindFormMaxParseMemory caps the in-memory portion of a multipart
69+
// body. Bytes beyond this are spilled to temporary files on disk by
70+
// the stdlib parser. 0 (the default) defers to the stdlib's 32 MB.
71+
//
72+
// This option does NOT cap total body bytes — see [BindFormMaxBody]
73+
// for that. The default body cap ([DefaultMaxUploadBodySize] = 32 MB)
74+
// is applied even when this option is not supplied, so out of the box
75+
// BindForm is bounded; callers with stricter or looser requirements
76+
// adjust via [BindFormMaxBody].
77+
func BindFormMaxParseMemory(n int64) BindOption {
78+
return func(c *bindConfig) { c.maxParseMemory = n }
79+
}
80+
81+
// BindFormMaxBody caps the size of the body read from a http form before parsing.
82+
//
83+
// The limit is set to 32MB by default. This default limit is applied for any n=0.
84+
//
85+
// The limit is disabled for n<0, assuming the caller has already capped the body size upstream.
86+
func BindFormMaxBody(n int64) BindOption {
87+
return func(c *bindConfig) { c.maxBody = n }
88+
}
89+
90+
// BindFormMaxFiles rejects parses where the total number of file
91+
// parts across all field names exceeds n. 0 (the default) means no
92+
// cap. Exceeding the cap is a fatal error — [BindForm] returns
93+
// fatal=true and no per-file binders run.
94+
func BindFormMaxFiles(n int) BindOption {
95+
return func(c *bindConfig) { c.maxFiles = n }
96+
}
97+
98+
// BindFormMaxFilenameLen rejects per-file headers whose Filename
99+
// length exceeds n. 0 means no cap; the default applied when this
100+
// option is not supplied is [DefaultMaxUploadFilenameLength]. The
101+
// cap is a per-field bind error (non-fatal); other declared files
102+
// still run.
103+
func BindFormMaxFilenameLen(n int) BindOption {
104+
return func(c *bindConfig) { c.maxFilenameLen = n }
105+
}
106+
107+
// BindFormFile declares a file field to bind under the given form
108+
// name. If required is true and the field is absent, [BindForm]
109+
// produces the per-field error
110+
//
111+
// errors.NewParseError(name, "formData", "", http.ErrMissingFile)
112+
//
113+
// If required is false, absence is silent (no error, no bind).
114+
//
115+
// The bind callback runs only when the field is present. It is the
116+
// site where both validation and assignment happen — see [FileBinder].
117+
//
118+
// FileHeader.Filename is attacker-controlled text; the binder MUST
119+
// NOT use it directly as a filesystem path. The helper does not
120+
// touch the filesystem.
121+
func BindFormFile(name string, required bool, bind FileBinder) BindOption {
122+
return func(c *bindConfig) {
123+
c.files = append(c.files, formFileSpec{
124+
name: name,
125+
required: required,
126+
bind: bind,
127+
})
128+
}
129+
}
130+
131+
// BindForm parses r as multipart/form-data, falling back to
132+
// application/x-www-form-urlencoded when the request is not
133+
// multipart. On success, r.MultipartForm and r.PostForm are populated;
134+
// the caller can read non-file form values via [Values](r.Form) after
135+
// the call returns.
136+
//
137+
// All errors produced by BindForm itself (parse failure, missing
138+
// required field, cap exceeded) are *errors.ParseError values built
139+
// via [errors.NewParseError], matching the untyped
140+
// middleware/parameter.go path. Errors returned by per-file binders
141+
// flow through verbatim — binders own their HTTP-aware error shape.
142+
//
143+
// Per-file binders declared via [BindFormFile] run in declaration
144+
// order after a successful parse. Their errors are accumulated and
145+
// returned wrapped in [errors.CompositeValidationError]; the caller
146+
// typically appends the returned err to its own []error and continues
147+
// with non-file parameter binding.
148+
//
149+
// Return semantics:
150+
//
151+
// - fatal=true, err!=nil: parse failure or a hard cap (e.g.
152+
// [BindFormMaxFiles]) was exceeded. No per-file binders ran; the
153+
// caller MUST return err immediately.
154+
// - fatal=false, err!=nil: one or more per-file binders produced
155+
// errors. The form parsed successfully; r.Form is populated. The
156+
// caller appends err to its accumulator and continues.
157+
// - fatal=false, err==nil: full success.
158+
//
159+
// fatal==true implies err!=nil.
160+
//
161+
// Defaults applied out of the box:
162+
//
163+
// - Total body bytes capped at [DefaultMaxUploadBodySize] (32 MB)
164+
// via [http.MaxBytesReader]. Adjust with [BindFormMaxBody]
165+
// (negative n disables, when the caller has already capped the
166+
// body upstream).
167+
// - FileHeader.Filename length capped at
168+
// [DefaultMaxUploadFilenameLength]. Adjust with
169+
// [BindFormMaxFilenameLen].
170+
//
171+
// Caller responsibilities the helper does NOT cover:
172+
//
173+
// - Set http.Server.ReadTimeout / IdleTimeout to defend against
174+
// slow-read attacks.
175+
// - Decompress Content-Encoding: gzip request bodies upstream if
176+
// the API accepts them, using a size-limited reader.
177+
// - Treat FileHeader.Filename as untrusted user input; never use
178+
// it directly as a filesystem path.
179+
func BindForm(r *http.Request, opts ...BindOption) (fatal bool, err error) {
180+
cfg := bindConfig{
181+
maxFilenameLen: DefaultMaxUploadFilenameLength,
182+
}
183+
for _, opt := range opts {
184+
opt(&cfg)
185+
}
186+
187+
if perr := parseFormBody(r, cfg.maxParseMemory, cfg.maxBody); perr != nil {
188+
// Body-cap hit gets the 413 status; everything else maps to a
189+
// 400 ParseError. parseFormBody returns the raw stdlib error
190+
// in both cases — the HTTP-aware wrapping happens here.
191+
var maxBytesErr *http.MaxBytesError
192+
if stderrors.As(perr, &maxBytesErr) {
193+
return true, errors.New(http.StatusRequestEntityTooLarge, "formData: %v", perr)
194+
}
195+
return true, errors.NewParseError("body", "formData", "", perr)
196+
}
197+
198+
if cfg.maxFiles > 0 {
199+
if got := countFileParts(r); got > cfg.maxFiles {
200+
return true, errors.NewParseError("body", "formData", "",
201+
fmt.Errorf("multipart form contains %d file parts, exceeds limit %d", got, cfg.maxFiles))
202+
}
203+
}
204+
205+
var bindErrs []error
206+
for _, spec := range cfg.files {
207+
if e := bindFormFile(r, spec, cfg.maxFilenameLen); e != nil {
208+
bindErrs = append(bindErrs, e)
209+
}
210+
}
211+
if len(bindErrs) > 0 {
212+
return false, errors.CompositeValidationError(bindErrs...)
213+
}
214+
return false, nil
215+
}
216+
217+
// parseFormBody parses the request body. Content-Type drives the
218+
// parser: multipart/form-data → r.ParseMultipartForm, everything else
219+
// → r.ParseForm (stdlib's parsePostForm only actually reads the body
220+
// when Content-Type is application/x-www-form-urlencoded, so calling
221+
// ParseForm is safe for unrecognised types).
222+
//
223+
// Caveat: ParseMultipartForm calls ParseForm internally and discards its error
224+
// when the body turns out not to be multipart, returning ErrNotMultipart instead
225+
// — the subsequent retry then short-circuits because r.PostForm is already
226+
// set. Content-type-based routing avoids the lossy detour.
227+
//
228+
// Returns the raw stdlib error on failure; the caller (BindForm)
229+
// handles HTTP-aware wrapping (413 for MaxBytesError, 400 ParseError
230+
// otherwise).
231+
//
232+
// maxMemory == 0 falls through to the stdlib default (32 MB).
233+
// maxBody == 0 defaults to DefaultMaxUploadBodySize; maxBody < 0
234+
// disables the body cap (caller has capped upstream).
235+
func parseFormBody(r *http.Request, maxMemory, maxBody int64) error {
236+
if r.Body != nil && maxBody >= 0 {
237+
if maxBody == 0 {
238+
maxBody = DefaultMaxUploadBodySize
239+
}
240+
r.Body = http.MaxBytesReader(nil, r.Body, maxBody)
241+
}
242+
243+
mt, _, _ := ContentType(r.Header)
244+
if mt == MultipartFormMime {
245+
//nolint:gosec // G120: false positive (gosec doesn't track the Body). See https://github.com/securego/gosec/blob/de65614d10a6b84029e3e1215567b8ce7e490f23/testutils/g120_samples.go#L57
246+
return r.ParseMultipartForm(maxMemory)
247+
}
248+
return r.ParseForm()
249+
}
250+
251+
func countFileParts(r *http.Request) int {
252+
if r.MultipartForm == nil {
253+
return 0
254+
}
255+
var n int
256+
for _, fhs := range r.MultipartForm.File {
257+
n += len(fhs)
258+
}
259+
260+
return n
261+
}
262+
263+
func bindFormFile(r *http.Request, spec formFileSpec, maxFilenameLen int) error {
264+
file, header, err := r.FormFile(spec.name)
265+
if err != nil {
266+
if stderrors.Is(err, http.ErrMissingFile) {
267+
if spec.required {
268+
return errors.New(http.StatusBadRequest, "formData: %v", http.ErrMissingFile)
269+
}
270+
271+
return nil
272+
}
273+
274+
return errors.NewParseError(spec.name, "formData", "", err)
275+
}
276+
277+
if maxFilenameLen > 0 && len(header.Filename) > maxFilenameLen {
278+
preview := header.Filename
279+
if len(preview) > filenamePreviewLen {
280+
preview = preview[:filenamePreviewLen]
281+
}
282+
return errors.NewParseError(spec.name, "formData", preview,
283+
fmt.Errorf("filename length %d exceeds limit %d", len(header.Filename), maxFilenameLen))
284+
}
285+
286+
if spec.bind == nil {
287+
return nil
288+
}
289+
290+
return spec.bind(file, header)
291+
}

0 commit comments

Comments
 (0)