Skip to content

feat(request): add FormFileWithConfig for size and content-type limits - #44

Merged
KARTIKrocks merged 7 commits into
mainfrom
feat/upload-size-and-type-limits
Jul 20, 2026
Merged

feat(request): add FormFileWithConfig for size and content-type limits#44
KARTIKrocks merged 7 commits into
mainfrom
feat/upload-size-and-type-limits

Conversation

@KARTIKrocks

@KARTIKrocks KARTIKrocks commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Summary

Uploads previously had no framework-level guard: callers hand-rolled io.LimitReader plus magic-byte sniffing, and getting either wrong is a security problem rather than a cosmetic one.

FormFileWithConfig applies the size limit with http.MaxBytesReader before ParseMultipartForm runs, so an oversized upload is rejected without first being buffered to memory or spooled to disk. A post-hoc fh.Size check would have let a client stream gigabytes to /tmp before receiving a 413; that check remains only as a backstop for a form some middleware already parsed.

AllowedTypes is matched against the type detected from the file's leading bytes via http.DetectContentType, never the multipart part's Content-Type header, which is client-supplied and trivially forged. Since sniffing has to open the file anyway, the opened multipart.File is returned rewound so callers don't reopen it.

Also fixes FormFile flattening every failure into 400: an oversized body now returns 413 and a non-multipart request 415, matching BindMultipart. The two err.Error() == "http: request body too large" string comparisons in form.go are replaced with errors.As on *http.MaxBytesError.

Motivation

Fixes #

Changes

Checklist

  • fmt, vet, lint, test, build passes (make all)
  • New code has tests where appropriate
  • Breaking changes are documented

Summary by CodeRabbit

  • New Features

    • Added configurable multipart file upload support with server-side limits for total size and memory usage.
    • Added allowed content-type validation using detected file bytes (supports wildcard patterns).
    • Oversized uploads are rejected early with HTTP 413; unsupported content types return HTTP 415.
  • Bug Fixes

    • Improved error reporting for missing/invalid file fields and standardized “request too large” detection.
  • Documentation

    • Updated Quick Start with an upload-limit and allowlist example, including expected HTTP behaviors.
  • Tests

    • Added extensive coverage for upload sizing, type sniffing/rewinding, and error codes.
  • Chores

    • Updated lint configuration for consistent Go versioning.

Uploads previously had no framework-level guard: callers hand-rolled
io.LimitReader plus magic-byte sniffing, and getting either wrong is a
security problem rather than a cosmetic one.

FormFileWithConfig applies the size limit with http.MaxBytesReader before
ParseMultipartForm runs, so an oversized upload is rejected without first
being buffered to memory or spooled to disk. A post-hoc fh.Size check would
have let a client stream gigabytes to /tmp before receiving a 413; that
check remains only as a backstop for a form some middleware already parsed.

AllowedTypes is matched against the type detected from the file's leading
bytes via http.DetectContentType, never the multipart part's Content-Type
header, which is client-supplied and trivially forged. Since sniffing has to
open the file anyway, the opened multipart.File is returned rewound so
callers don't reopen it.

Also fixes FormFile flattening every failure into 400: an oversized body now
returns 413 and a non-multipart request 415, matching BindMultipart. The two
`err.Error() == "http: request body too large"` string comparisons in form.go
are replaced with errors.As on *http.MaxBytesError.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@KARTIKrocks, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2d96071d-2a3e-43a6-8aee-213477038165

📥 Commits

Reviewing files that changed from the base of the PR and between 13dac41 and 517329d.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (6)
  • AGENTS.md
  • README.md
  • request/bind.go
  • response/content.go
  • response/content_test.go
  • response/stream.go
📝 Walkthrough

Walkthrough

Adds FormFileWithConfig with upload size limits, memory controls, content-type sniffing, allowlists, structured errors, integration updates, comprehensive tests, lint configuration, and README usage documentation.

Changes

Multipart upload handling

Layer / File(s) Summary
Upload configuration and handler
request/file.go
Adds FileConfig, default upload limits, multipart parsing, size enforcement, and file retrieval.
Content validation and error mapping
request/file.go
Sniffs file bytes, rewinds readers, applies exact and wildcard media-type allowlists, and maps failures to HTTP errors.
Existing form error integration
request/form.go
Uses shared error classification for file lookup and oversized form parsing failures.
Upload behavior coverage and usage example
request/file_test.go, README.md, .golangci.yml
Covers accepted, rejected, pre-parsed, spooled, empty, wildcard, and boundary cases; documents the helper; and pins the lint language version.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant FormFileWithConfig
  participant MultipartParser
  participant sniffContentType
  participant fileError
  Client->>FormFileWithConfig: Send multipart upload
  FormFileWithConfig->>MultipartParser: Parse bounded request
  MultipartParser-->>FormFileWithConfig: Return file and metadata
  FormFileWithConfig->>sniffContentType: Detect type from file bytes
  sniffContentType-->>FormFileWithConfig: Return normalized media type
  FormFileWithConfig->>fileError: Map limit or type failure
  fileError-->>Client: Return structured HTTP error
Loading

Poem

I’m a rabbit with a file in my hat,
Size limits keep the upload flat.
Bytes reveal the type they bear,
Wildcards welcome images there.
I close the file and hop away!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding FormFileWithConfig with upload size and content-type limits.
Description check ✅ Passed The description follows the template and covers summary, motivation, changes, and checklist, though the issue reference and changes list are still generic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/upload-size-and-type-limits

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Jul 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.40000% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
request/file.go 84.00% 7 Missing and 5 partials ⚠️
response/content.go 93.47% 2 Missing and 1 partial ⚠️
request/form.go 66.66% 1 Missing ⚠️
response/stream.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@request/file.go`:
- Around line 15-123: Update the FormFileWithConfig documentation to state that
callers using a non-standard HTTP server or transport must call
r.MultipartForm.RemoveAll() to clean up multipart temporary files, while
retaining the existing caller responsibility to close the returned file.
- Around line 106-111: Update the sniffContentType error branch and fileError
default branch to preserve the underlying err by using the errors package’s
supported cause-wrapping API (such as WithCause or Wrap), while keeping the
existing client-facing messages unchanged. Verify both returned API errors
retain errors.Is/errors.As traceability and remove any silent discard of the
original error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c2706f26-470e-47a1-aaef-dda5e533436f

📥 Commits

Reviewing files that changed from the base of the PR and between 7140dc9 and 9b3309f.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (4)
  • README.md
  • request/file.go
  • request/file_test.go
  • request/form.go

Comment thread request/file.go
Comment on lines +15 to +123
// DefaultMaxUploadSize is the default body limit applied by FormFileWithConfig
// when FileConfig.MaxBytes is zero.
const DefaultMaxUploadSize = 10 << 20 // 10 MB

// sniffLen is the number of leading bytes inspected to detect a file's real
// content type. http.DetectContentType never looks at more than this.
const sniffLen = 512

// FileConfig constrains an uploaded file.
type FileConfig struct {
// MaxBytes is the maximum allowed request body size in bytes. It is
// enforced before the body is read, so an oversized upload is rejected
// without being buffered to memory or disk.
// Defaults to DefaultMaxUploadSize if zero.
MaxBytes int64

// MaxMemory is the maximum memory (in bytes) used for parsing the
// multipart form before spilling to disk.
// Defaults to DefaultMaxMultipartMemory if zero.
MaxMemory int64

// AllowedTypes is an allowlist of media types, matched against the type
// detected from the file's own bytes rather than the client-supplied
// Content-Type header. Entries may be exact ("image/png"), a wildcard
// subtype ("image/*"), or "*/*"; matching is case-insensitive.
// An empty slice allows any content type.
//
// Note that http.DetectContentType recognises a fixed set of signatures
// and falls back to "application/octet-stream" for formats it does not
// know, so an allowlist for an exotic type may need that entry (or a
// caller-side check) to accept legitimate files.
AllowedTypes []string
}

// FormFileWithConfig returns the first file for the given form field, enforcing
// a size limit and an optional content-type allowlist.
//
// Unlike FormFile it also returns the opened file, positioned at the start:
//
// f, fh, err := request.FormFileWithConfig(r, "avatar", request.FileConfig{
// MaxBytes: 5 << 20,
// AllowedTypes: []string{"image/png", "image/jpeg"},
// })
// if err != nil {
// return err
// }
// defer f.Close()
//
// The caller is responsible for closing the returned file.
//
// Content types are detected with http.DetectContentType over the first 512
// bytes of the file. The multipart part's own Content-Type header is not
// consulted, since it is supplied by the client and trivially forged.
func FormFileWithConfig(r *http.Request, field string, cfg FileConfig) (multipart.File, *multipart.FileHeader, error) {
maxBytes := cfg.MaxBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxUploadSize
}
maxMemory := cfg.MaxMemory
if maxMemory <= 0 {
maxMemory = DefaultMaxMultipartMemory
}

// Limit the body before anything reads it. Once the form has been parsed
// the bytes are already spooled, so a limit applied afterwards would be
// no protection at all.
if r.MultipartForm == nil {
if r.Body == nil || r.Body == http.NoBody {
return nil, nil, errors.BadRequest("Request body is required")
}
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)

if err := r.ParseMultipartForm(maxMemory); err != nil {
return nil, nil, fileError(field, err)
}
}

f, fh, err := r.FormFile(field)
if err != nil {
return nil, nil, fileError(field, err)
}

// Backstop for a form parsed before this call (by middleware, or by an
// earlier FormFile), where the body limit above was never applied.
if fh.Size > maxBytes {
_ = f.Close()
return nil, nil, errors.New(errors.CodeRequestTooLarge,
fmt.Sprintf("File %q exceeds the maximum size of %d bytes", field, maxBytes)).
WithStatus(http.StatusRequestEntityTooLarge)
}

if len(cfg.AllowedTypes) > 0 {
detected, err := sniffContentType(f)
if err != nil {
_ = f.Close()
return nil, nil, errors.BadRequest(fmt.Sprintf("Failed to read file field %q", field))
}
if !mediaTypeAllowed(detected, cfg.AllowedTypes) {
_ = f.Close()
return nil, nil, errors.New(errors.CodeUnsupportedMedia,
fmt.Sprintf("File type %s is not allowed, expected one of: %s",
detected, strings.Join(cfg.AllowedTypes, ", "))).
WithStatus(http.StatusUnsupportedMediaType).
WithField(field, "unsupported file type")
}
}

return f, fh, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Document multipart temp-file cleanup expectations.

FormFileWithConfig calls r.ParseMultipartForm, which can spool files to disk. For a request served through the standard net/http.Server, the server calls req.MultipartForm.RemoveAll() after the handler returns, so this is normally not a leak. However, that guarantee is server-specific (and historically inconsistent across HTTP/1 vs HTTP/2 paths) and isn't mentioned anywhere in this file's docs. Since this is a general-purpose library function, consider documenting on FormFileWithConfig (similar to the existing "caller is responsible for closing" note) that callers using a non-stdlib server/transport are responsible for calling r.MultipartForm.RemoveAll() themselves.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@request/file.go` around lines 15 - 123, Update the FormFileWithConfig
documentation to state that callers using a non-standard HTTP server or
transport must call r.MultipartForm.RemoveAll() to clean up multipart temporary
files, while retaining the existing caller responsibility to close the returned
file.

Comment thread request/file.go
…rors

The sniff-failure branch and fileError's fallback branch both discarded the
original error, so an unexpected multipart failure surfaced as a bare 400 with
nothing to debug from. Both now Wrap it.

Error.Err is json:"-" and Message is unchanged, so this is invisible to
clients; it only restores errors.Is/As traceability for logs. Covered by a
test asserting both halves: the cause is reachable, and it does not appear in
the serialized response.
golangci-lint infers the analysis language version from go.mod (1.22),
and at that version staticcheck does not model t.Fatal as terminating,
so it reported two false 'possible nil pointer dereference' warnings in
httpclient/httpclient_test.go. Genuine SA5011 findings are still
reported at 1.23.
Applies to BindMultipart, FormFile, FormFiles, and FormFileWithConfig
alike, so it belongs on the package rather than any one helper.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
request/file_test.go (1)

314-339: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Add t.Parallel() to the test and its subtests.

As per path instructions, tests should use t.Parallel() where safe. TestMediaTypeAllowed is a pure function test without shared state, making it safe for parallel execution.

⚡ Proposed fix
 func TestMediaTypeAllowed(t *testing.T) {
+	t.Parallel()
 	tests := []struct {
 		name     string
 		detected string
 		allowed  []string
 		want     bool
 	}{
 		{"exact match", "image/png", []string{"image/png"}, true},
 		{"case insensitive", "image/png", []string{"IMAGE/PNG"}, true},
 		{"second entry", "image/jpeg", []string{"image/png", "image/jpeg"}, true},
 		{"no match", "text/plain", []string{"image/png"}, false},
 		{"wildcard match", "image/gif", []string{"image/*"}, true},
 		{"wildcard no match", "video/mp4", []string{"image/*"}, false},
 		{"wildcard is not a prefix match", "imagexml/foo", []string{"image/*"}, false},
 		{"empty allowlist", "image/png", nil, false},
 		{"match anything", "application/octet-stream", []string{"*/*"}, true},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
 			if got := mediaTypeAllowed(tt.detected, tt.allowed); got != tt.want {
 				t.Errorf("mediaTypeAllowed(%q, %v) = %v, want %v",
 					tt.detected, tt.allowed, got, tt.want)
 			}
 		})
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@request/file_test.go` around lines 314 - 339, Add t.Parallel() at the start
of TestMediaTypeAllowed and within each t.Run subtest, preserving the existing
table-driven assertions.

Source: Path instructions

request/file.go (1)

68-68: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the multipart cleanup note to FormFileWithConfig Callers using a non-standard HTTP server need to call r.MultipartForm.RemoveAll() after use to release temp files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@request/file.go` at line 68, Add a documentation note to FormFileWithConfig
stating that callers using a non-standard HTTP server must call
r.MultipartForm.RemoveAll() after processing the uploaded file to release
temporary files.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@request/file_test.go`:
- Around line 314-339: Add t.Parallel() at the start of TestMediaTypeAllowed and
within each t.Run subtest, preserving the existing table-driven assertions.

In `@request/file.go`:
- Line 68: Add a documentation note to FormFileWithConfig stating that callers
using a non-standard HTTP server must call r.MultipartForm.RemoveAll() after
processing the uploaded file to release temporary files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9473b869-6737-4817-a370-f059aa50deb6

📥 Commits

Reviewing files that changed from the base of the PR and between 9b3309f and 13dac41.

📒 Files selected for processing (3)
  • .golangci.yml
  • request/file.go
  • request/file_test.go

response.File and response.Reader write the body in a single pass, so
neither can back a <video> that needs to seek or a resumable download.
Both new helpers delegate the transfer to http.ServeContent, which owns
Range parsing, conditional GETs, HEAD, and 416 handling, and layer the
Content-Disposition, ETag, and Cache-Control handling on top.

File now shares the Content-Disposition builder rather than repeating it.
Follow-up to 393ddeb, which staged only tracked files and so omitted
the two new ones.
@KARTIKrocks
KARTIKrocks merged commit 8700ca9 into main Jul 20, 2026
11 checks passed
@KARTIKrocks
KARTIKrocks deleted the feat/upload-size-and-type-limits branch July 20, 2026 05:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants