Skip to content

fix(fuzz): prevent concurrent map writes in MultiPartForm.Decode#7029

Open
yusei-wy wants to merge 2 commits intoprojectdiscovery:devfrom
yusei-wy:fix/multipart-concurrent-map-writes
Open

fix(fuzz): prevent concurrent map writes in MultiPartForm.Decode#7029
yusei-wy wants to merge 2 commits intoprojectdiscovery:devfrom
yusei-wy:fix/multipart-concurrent-map-writes

Conversation

@yusei-wy
Copy link
Contributor

@yusei-wy yusei-wy commented Feb 25, 2026

Summary

Fixes #7028

MultiPartForm is the only stateful DataFormat implementation (it stores boundary and filesMetadata), but it was registered as a singleton via dataformat.Get() and shared across all goroutines. When multiple targets are scanned concurrently, parallel writes to m.filesMetadata cause fatal error: concurrent map writes.

Fix: Create a fresh MultiPartForm instance per parseBody call instead of reusing the singleton.

Changes

File Change
pkg/fuzz/component/body.go parseBody creates NewMultiPartForm() + ParseBoundary() for multipart instead of calling dataformat.Get()
pkg/fuzz/component/value.go Add encoder field to Value so the per-request MultiPartForm instance is reused for re-encoding
pkg/fuzz/dataformat/multipart_test.go Add TestMultiPartFormDecode_ConcurrentWithSeparateInstances (10 goroutines), boundary validation tests, file metadata tests
lib/multipart_race_test.go SDK-level regression test: 20 concurrent multipart targets via DASTMode() + LoadTargetsWithHttpData — fails on dev, passes with fix

Why the singleton is unsafe

Other DataFormat implementations (JSON, XML, Form, Raw) are stateless — Decode and Encode use only local variables. MultiPartForm is different:

  • ParseBoundary() writes to m.boundary (string field)
  • Decode() writes to m.filesMetadata (map field) at multipart.go:222

When executeTemplateWithTargets spawns worker goroutines for N targets, each goroutine calls:

dataformat.Get("multipart/form-data")  → same singleton
singleton.ParseBoundary(...)            → concurrent string write
singleton.Decode(...)                   → concurrent map write → fatal error

Why 1 target does not trigger the race

executeTemplateWithTargets sends targets through an unbuffered channel (pkg/core/executors.go:95). With 1 target, only 1 worker picks it up → sequential execution → no concurrent access. The race requires N > 1 targets.

Reproduction

# On dev branch — FAIL (data race detected):
go test -race -v -count=1 -timeout=120s -run TestMultiPartForm_ConcurrentMapWrites_SDK ./lib/

# On this branch — PASS:
go test -race -v -count=1 -timeout=120s -run TestMultiPartForm_ConcurrentMapWrites_SDK ./lib/

Full reproduction details including template YAML, proxify input format, and SDK test code: #7028

CI lint note

The golangci-lint job reports 3 pre-existing staticcheck QF1012 errors in pkg/js/libs/ldap/utils.go. These are unrelated to this PR (zero diff on that file) and already present on the dev branch:

pkg/js/libs/ldap/utils.go:222:2: QF1012: Use fmt.Fprintf(...) instead of WriteString(fmt.Sprintf(...))
pkg/js/libs/ldap/utils.go:223:2: QF1012: Use fmt.Fprintf(...) instead of WriteString(fmt.Sprintf(...))
pkg/js/libs/ldap/utils.go:225:3: QF1012: Use fmt.Fprintf(...) instead of WriteString(fmt.Sprintf(...))

Test plan

  • go test -race ./pkg/fuzz/dataformat/... — all multipart tests pass including concurrent instance test
  • go test -race -run TestMultiPartForm_ConcurrentMapWrites_SDK ./lib/ — SDK regression test passes (20 targets, no race)
  • Existing TestMultiPartFormComponent in body_test.go continues to pass
  • CI: make test (includes -race flag)
  • CI: make integration (includes fuzz-body-multipart-form-sqli.yaml)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced multipart form data handling with improved instance creation and management
    • Optimized encoder field assignment for data formatting operations
  • Tests

    • Added comprehensive tests for concurrent multipart form data processing
    • Added SDK-level integration tests validating multipart form handling
    • Verified data integrity across concurrent execution scenarios

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 25, 2026

Walkthrough

This pull request addresses a concurrent map writes fatal error in multipart form data fuzzing by replacing singleton decoder reuse with per-instance creation. It introduces an encoder tracking mechanism in Value to store decoder references and adds test coverage for concurrent multipart decoding scenarios.

Changes

Cohort / File(s) Summary
Multipart Decoder Instantiation
pkg/fuzz/component/body.go
Creates fresh MultiPartForm instances for each multipart form data decode operation instead of reusing the singleton from dataformat.Get(), and stores the created instance back to b.value.encoder for potential reuse within a value context.
Value Encoder Tracking
pkg/fuzz/component/value.go
Adds private encoder field to Value struct, updates Clone to propagate it, and introduces private encode() helper method that delegates to encoder.Encode if available or falls back to dataformat.Encode with the value's dataFormat.
Concurrent Multipart Decoding Tests
pkg/fuzz/dataformat/multipart_test.go, lib/sdk_test.go
Adds TestMultiPartFormDecode_ConcurrentWithSeparateInstances for concurrent decode validation using separate instances, and TestMultiPartForm_ConcurrentMapWrites_SDK integration test that reproduces the original race condition across multiple targets via SDK.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Concurrent maps no more shall break,
Fresh instances we now create,
No singleton's shared state to fear,
Each goroutine gets its own dear,
Multipart fuzzing, safe and sound!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title accurately summarizes the main change: preventing concurrent map writes in MultiPartForm.Decode, which is the core objective of the PR.
Linked Issues check ✅ Passed PR addresses all coding objectives from #7028: creates fresh MultiPartForm instances per request to prevent singleton reuse, adds encoder field to Value for instance reuse, and includes comprehensive tests validating concurrent safety.
Out of Scope Changes check ✅ Passed All changes are scoped to fixing concurrent map writes in multipart decoding: body.go instance creation, value.go encoder field, and new tests for concurrency validation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

MultiPartForm is the only stateful DataFormat (stores boundary and
filesMetadata) but was shared as a singleton via dataformat.Get().
When multiple goroutines fuzz multipart/form-data bodies in parallel,
concurrent writes to filesMetadata cause fatal error: concurrent map writes.

Create a fresh MultiPartForm instance per parseBody call instead of
reusing the singleton, and store it on Value.encoder for re-encoding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yusei-wy yusei-wy force-pushed the fix/multipart-concurrent-map-writes branch from 2b25f40 to 3397c07 Compare February 25, 2026 02:16
@yusei-wy yusei-wy marked this pull request as ready for review February 25, 2026 02:21
@auto-assign auto-assign bot requested a review from Mzack9999 February 25, 2026 02:21
@neo-by-projectdiscovery-dev
Copy link

neo-by-projectdiscovery-dev bot commented Feb 25, 2026

Neo - PR Security Review

No security issues found

Highlights

  • Adds SDK-level regression test TestMultiPartForm_ConcurrentMapWrites_SDK to validate the concurrent map writes fix with 20 parallel multipart targets
  • Test uses controlled test infrastructure (httptest.NewServer, os.CreateTemp with t.TempDir()) with no external attack surface

Comment @neo help for available commands. · Open in Neo

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
pkg/fuzz/component/value.go (1)

167-171: Optionally guard encoder selection to avoid stale encoder reuse.

As a defensive check, only use v.encoder when it matches the active v.dataFormat.

🛡️ Defensive tweak
 func (v *Value) encode(data dataformat.KV) (string, error) {
-	if v.encoder != nil {
+	if v.encoder != nil && v.encoder.Name() == v.dataFormat {
 		return v.encoder.Encode(data)
 	}
 	return dataformat.Encode(data, v.dataFormat)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fuzz/component/value.go` around lines 167 - 171, The encode method
currently unconditionally uses v.encoder when non-nil which can reuse an encoder
for the wrong format; change Value.encode to only call v.encoder.Encode(data) if
v.encoder is non-nil and its format matches v.dataFormat (e.g. via a
Format()/Kind() method or comparable identifier on the encoder); otherwise fall
back to dataformat.Encode(data, v.dataFormat) and optionally clear or replace
v.encoder to avoid future stale reuse. Ensure you reference Value.encode,
v.encoder and v.dataFormat when making the guard.
pkg/fuzz/dataformat/multipart_test.go (1)

388-390: Strengthen assertions to verify decoded multipart content, not only non-nil KV.

Right now this can pass even if decode returns an unexpected structure. Add at least one field/metadata assertion in each goroutine.

💡 Proposed test-strengthening diff
 			kv, err := mpf.Decode(body)
 			assert.NoError(t, err)
 			assert.NotNil(t, kv)
+			fileValues, ok := kv.Get("file").([]interface{})
+			assert.True(t, ok, "expected file field to decode as []interface{}")
+			assert.Len(t, fileValues, 1)
+			assert.Equal(t, "file content", fileValues[0])
+
+			metadata, exists := mpf.GetFileMetadata("file")
+			assert.True(t, exists, "expected file metadata to be present")
+			assert.Equal(t, "test.txt", metadata.Filename)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/fuzz/dataformat/multipart_test.go` around lines 388 - 390, The test
currently only checks that mpf.Decode(body) returns no error and a non-nil kv;
update each goroutine after kv is obtained (the Decode call in the test using
mpf.Decode and variable kv) to assert specific expected content from the decoded
multipart structure — e.g., check a known field key/value or metadata entry
exists and equals the expected string (or that a particular part count/field
name is present) so the test fails if the structure is incorrect rather than
merely non-nil.
lib/multipart_race_test.go (1)

122-127: Assert callback activity so the regression test proves execution depth.

Right now the test can pass without confirming any result events were produced.

✅ Suggested assertion hardening
 import (
 	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
 	"os"
+	"sync/atomic"
 	"testing"
@@
-	err = ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
+	var resultCount atomic.Int32
+	err = ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
+		resultCount.Add(1)
 		t.Logf("Result: %s", event.TemplateID)
 	})
-	if err != nil {
-		t.Errorf("ExecuteCallbackWithCtx error: %v", err)
-	}
+	require.NoError(t, err)
+	require.Greater(t, resultCount.Load(), int32(0), "expected at least one callback event")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/multipart_race_test.go` around lines 122 - 127, The test currently calls
ne.ExecuteCallbackWithCtx(...) without verifying the callback ran; modify the
test to record callback activity (e.g., increment an atomic counter or send a
value on a channel from inside the callback passed to ExecuteCallbackWithCtx)
and after ExecuteCallbackWithCtx returns assert that at least one ResultEvent
was observed (counter > 0 or channel received). Use the existing ResultEvent
parameter in the callback to optionally validate TemplateID if desired and fail
the test with t.Fatalf/t.Errorf when no events were produced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/multipart_race_test.go`:
- Around line 122-127: The test currently calls ne.ExecuteCallbackWithCtx(...)
without verifying the callback ran; modify the test to record callback activity
(e.g., increment an atomic counter or send a value on a channel from inside the
callback passed to ExecuteCallbackWithCtx) and after ExecuteCallbackWithCtx
returns assert that at least one ResultEvent was observed (counter > 0 or
channel received). Use the existing ResultEvent parameter in the callback to
optionally validate TemplateID if desired and fail the test with
t.Fatalf/t.Errorf when no events were produced.

In `@pkg/fuzz/component/value.go`:
- Around line 167-171: The encode method currently unconditionally uses
v.encoder when non-nil which can reuse an encoder for the wrong format; change
Value.encode to only call v.encoder.Encode(data) if v.encoder is non-nil and its
format matches v.dataFormat (e.g. via a Format()/Kind() method or comparable
identifier on the encoder); otherwise fall back to dataformat.Encode(data,
v.dataFormat) and optionally clear or replace v.encoder to avoid future stale
reuse. Ensure you reference Value.encode, v.encoder and v.dataFormat when making
the guard.

In `@pkg/fuzz/dataformat/multipart_test.go`:
- Around line 388-390: The test currently only checks that mpf.Decode(body)
returns no error and a non-nil kv; update each goroutine after kv is obtained
(the Decode call in the test using mpf.Decode and variable kv) to assert
specific expected content from the decoded multipart structure — e.g., check a
known field key/value or metadata entry exists and equals the expected string
(or that a particular part count/field name is present) so the test fails if the
structure is incorrect rather than merely non-nil.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d771daa and 3397c07.

📒 Files selected for processing (4)
  • lib/multipart_race_test.go
  • pkg/fuzz/component/body.go
  • pkg/fuzz/component/value.go
  • pkg/fuzz/dataformat/multipart_test.go

Copy link
Member

Choose a reason for hiding this comment

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

Just put this in the existing test file.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Moved to lib/sdk_test.go.

Self-contained test using httptest server with 20 concurrent multipart
targets via DASTMode() + LoadTargetsWithHttpData. Fails on dev branch
with DATA RACE, passes with fix applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@yusei-wy yusei-wy force-pushed the fix/multipart-concurrent-map-writes branch from 3397c07 to b80d637 Compare February 25, 2026 07:19
@yusei-wy yusei-wy requested a review from dwisiswant0 February 25, 2026 07:19
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/sdk_test.go (1)

170-175: Add one assertion that callback path was actually exercised.

At Line 170-Line 175, the test only checks returned error. A zero-event execution would still pass and weaken regression signal. Track callback count and assert it is > 0.

✅ Suggested strengthening diff
 import (
 	"context"
 	"fmt"
 	"log"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
 	"os"
+	"sync/atomic"
 	"testing"
 	"time"
@@
-	err = ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
+	var callbackCount int32
+	err = ne.ExecuteCallbackWithCtx(context.Background(), func(event *output.ResultEvent) {
+		atomic.AddInt32(&callbackCount, 1)
 		t.Logf("Result: %s", event.TemplateID)
 	})
 	if err != nil {
 		t.Errorf("ExecuteCallbackWithCtx error: %v", err)
 	}
+	require.Greater(t, atomic.LoadInt32(&callbackCount), int32(0), "expected at least one result callback")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/sdk_test.go` around lines 170 - 175, The test currently calls
ne.ExecuteCallbackWithCtx but only checks the returned error; add a counter to
assert the callback was actually invoked. Declare a counter (e.g., var cbCount
int32) before calling ne.ExecuteCallbackWithCtx, increment it inside the
callback passed to ExecuteCallbackWithCtx (use atomic.AddInt32 to be safe for
concurrent invocations), then after the call assert with t.Fatalf or t.Errorf
that atomic.LoadInt32(&cbCount) > 0 so the output.ResultEvent callback path was
exercised. Ensure references use the existing ne.ExecuteCallbackWithCtx and
output.ResultEvent symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/sdk_test.go`:
- Around line 170-175: The test currently calls ne.ExecuteCallbackWithCtx but
only checks the returned error; add a counter to assert the callback was
actually invoked. Declare a counter (e.g., var cbCount int32) before calling
ne.ExecuteCallbackWithCtx, increment it inside the callback passed to
ExecuteCallbackWithCtx (use atomic.AddInt32 to be safe for concurrent
invocations), then after the call assert with t.Fatalf or t.Errorf that
atomic.LoadInt32(&cbCount) > 0 so the output.ResultEvent callback path was
exercised. Ensure references use the existing ne.ExecuteCallbackWithCtx and
output.ResultEvent symbols.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3397c07 and b80d637.

📒 Files selected for processing (1)
  • lib/sdk_test.go

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.

fatal error: concurrent map writes in MultiPartForm.Decode during multipart/form-data fuzzing

2 participants