CVE-2026-48518
Description
MultiJuicer 8.0.0-10.0.0 vulnerable to login CSRF via the team join endpoint allowing attackers to force victims into their team, fixed in v10.0.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
MultiJuicer 8.0.0-10.0.0 vulnerable to login CSRF via the team join endpoint allowing attackers to force victims into their team, fixed in v10.0.1.
Vulnerability
The team join endpoint (POST /multi-juicer/api/teams/{team}/join) in MultiJuicer versions 8.0.0 through 10.0.0 accepts requests with any Content-Type, including text/plain. Because text/plain does not trigger a CORS preflight, a cross-site request forgery (CSRF) attack is possible without requiring a preflight check [1], [2], [3].
Exploitation
An attacker hosts an HTML form on a page they control with enctype="text/plain" that auto-submits to the vulnerable endpoint using JavaScript. The form payload is crafted to mimic a valid JSON body containing a passcode field. The victim only needs to visit the attacker-controlled page while having network access to the MultiJuicer deployment. No prior authentication is required, and SameSite=Strict on the session cookie does not prevent the attack because the form submission sets a new cookie rather than relying on an existing one [2].
Impact
A successful attacker forces the victim's browser to log in as the attacker's team. The victim then unknowingly solves Juice Shop challenges under the attacker's team identity. In a Capture The Flag (CTF) context, the attacker can inflate their team's score using the victim's activity. Additionally, any sensitive data the victim enters into their Juice Shop instance is accessible to the attacker's instance [2].
Mitigation
The vulnerability is fixed in MultiJuicer version 10.0.1 [2]. The fix requires the Content-Type: application/json header on the join endpoint and all other JSON POST endpoints, which browsers cannot set on a cross-site form submission without triggering a CORS preflight [1], [2].
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=8.0.0, <=10.0.0
Patches
175b08c3fdda4Validate content-type header for JSON endpoints
5 files changed · +124 −4
internal/routes/middleware/contenttype.go+24 −0 added@@ -0,0 +1,24 @@ +package middleware + +import ( + "mime" + "net/http" + "strings" +) + +// RequireJSONContentType rejects requests whose Content-Type is not application/json. +// Cross-site form submissions can only set Content-Type to application/x-www-form-urlencoded, +// multipart/form-data, or text/plain without triggering a CORS preflight, so enforcing +// application/json prevents login CSRF (see issue #525). +func RequireJSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnsupportedMediaType) + w.Write([]byte(`{"message":"Content-Type must be application/json"}`)) + return + } + next.ServeHTTP(w, r) + }) +}
internal/routes/middleware/contenttype_test.go+49 −0 added@@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRequireJSONContentType(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := RequireJSONContentType(next) + + cases := []struct { + name string + contentType string + wantStatus int + wantCalled bool + }{ + {"accepts application/json", "application/json", http.StatusOK, true}, + {"accepts application/json with charset", "application/json; charset=utf-8", http.StatusOK, true}, + {"accepts uppercase", "APPLICATION/JSON", http.StatusOK, true}, + {"rejects missing Content-Type", "", http.StatusUnsupportedMediaType, false}, + {"rejects text/plain", "text/plain", http.StatusUnsupportedMediaType, false}, + {"rejects form-urlencoded", "application/x-www-form-urlencoded", http.StatusUnsupportedMediaType, false}, + {"rejects multipart", "multipart/form-data; boundary=----foo", http.StatusUnsupportedMediaType, false}, + {"rejects malformed Content-Type", "this is not a media type", http.StatusUnsupportedMediaType, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + called = false + req := httptest.NewRequest("POST", "/", nil) + if tc.contentType != "" { + req.Header.Set("Content-Type", tc.contentType) + } + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tc.wantStatus, rr.Code) + assert.Equal(t, tc.wantCalled, called) + }) + } +}
internal/routes/private/routes.go+2 −1 modified@@ -6,9 +6,10 @@ import ( "github.com/juice-shop/multi-juicer/internal/bundle" "github.com/juice-shop/multi-juicer/internal/metrics" + "github.com/juice-shop/multi-juicer/internal/routes/middleware" ) func AddRoutes(ctx context.Context, mux *http.ServeMux, b *bundle.Bundle) { - mux.Handle("POST /team/{team}/webhook", metrics.TrackRequestMetrics(metrics.RequestTypeAPIInternal, NewSolutionsWebhookHandler(b))) + mux.Handle("POST /team/{team}/webhook", metrics.TrackRequestMetrics(metrics.RequestTypeAPIInternal, middleware.RequireJSONContentType(NewSolutionsWebhookHandler(b)))) mux.Handle("/", metrics.TrackRequestMetrics(metrics.RequestTypeAPIInternal, newLLMGatewayHandler(ctx, b))) }
internal/routes/public/join_test.go+42 −0 modified@@ -55,6 +55,7 @@ func TestJoinHandler(t *testing.T) { t.Run("creates a deployment and service on join", func(t *testing.T) { req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -129,6 +130,7 @@ func TestJoinHandler(t *testing.T) { t.Run("set secure flag on team cookie when configured", func(t *testing.T) { req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -147,6 +149,7 @@ func TestJoinHandler(t *testing.T) { t.Run("refuses to create a team if max instances limit is reached", func(t *testing.T) { req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -182,6 +185,7 @@ func TestJoinHandler(t *testing.T) { } for _, team := range invalidTeamnames { req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server.ServeHTTP(rr, req) assert.Equal(t, http.StatusBadRequest, rr.Code, fmt.Sprintf("expected status code 400 for teamname '%s'", team)) @@ -190,6 +194,7 @@ func TestJoinHandler(t *testing.T) { t.Run("if team already exists then join requires a passcode", func(t *testing.T) { req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -208,6 +213,7 @@ func TestJoinHandler(t *testing.T) { t.Run("is able to join team when the requests includes a correct passcode", func(t *testing.T) { jsonPayload, _ := json.Marshal(map[string]string{"passcode": "02101791"}) req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), bytes.NewReader(jsonPayload)) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -226,6 +232,7 @@ func TestJoinHandler(t *testing.T) { t.Run("join is rejected when the passcode doesn't match", func(t *testing.T) { jsonPayload, _ := json.Marshal(map[string]string{"passcode": "00000000"}) req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), bytes.NewReader(jsonPayload)) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -244,6 +251,7 @@ func TestJoinHandler(t *testing.T) { t.Run("allows admins login with the correct passcode", func(t *testing.T) { jsonPayload, _ := json.Marshal(map[string]string{"passcode": "mock-admin-password"}) req, _ := http.NewRequest("POST", "/multi-juicer/api/teams/admin/join", bytes.NewReader(jsonPayload)) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -259,6 +267,7 @@ func TestJoinHandler(t *testing.T) { t.Run("admin login returns usual 'requires auth' response when it get's no request body passed", func(t *testing.T) { req, _ := http.NewRequest("POST", "/multi-juicer/api/teams/admin/join", nil) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -275,6 +284,7 @@ func TestJoinHandler(t *testing.T) { t.Run("admin account requires the correct passcod", func(t *testing.T) { jsonPayload, _ := json.Marshal(map[string]string{"passcode": "wrong-password"}) req, _ := http.NewRequest("POST", "/multi-juicer/api/teams/admin/join", bytes.NewReader(jsonPayload)) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -291,6 +301,7 @@ func TestJoinHandler(t *testing.T) { t.Run("admin login doesn't make any kubernetes api calls / creates not kubernetes resources", func(t *testing.T) { jsonPayload, _ := json.Marshal(map[string]string{"passcode": "mock-admin-password"}) req, _ := http.NewRequest("POST", "/multi-juicer/api/teams/admin/join", bytes.NewReader(jsonPayload)) + req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() server := http.NewServeMux() @@ -304,4 +315,35 @@ func TestJoinHandler(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) assert.Len(t, clientset.Actions(), 0) }) + + t.Run("rejects login CSRF via cross-site form submission (issue #525)", func(t *testing.T) { + // Browsers can only set Content-Type to application/x-www-form-urlencoded, + // multipart/form-data, or text/plain without triggering a CORS preflight. + // All three must be rejected so an attacker can't trick a victim into + // logging in as the attacker's team. + csrfContentTypes := []string{ + "text/plain", + "application/x-www-form-urlencoded", + "multipart/form-data; boundary=----WebKitFormBoundary", + "", + } + for _, ct := range csrfContentTypes { + jsonPayload := []byte(`{"passcode":"02101791","whatever":"`) + req, _ := http.NewRequest("POST", fmt.Sprintf("/multi-juicer/api/teams/%s/join", team), bytes.NewReader(jsonPayload)) + if ct != "" { + req.Header.Set("Content-Type", ct) + } + rr := httptest.NewRecorder() + + server := http.NewServeMux() + clientset := fake.NewClientset(multiJuicerDeployment, createTeam(team)) + bundle := testutil.NewTestBundleWithCustomFakeClient(clientset) + AddRoutes(server, bundle) + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnsupportedMediaType, rr.Code, fmt.Sprintf("expected 415 for Content-Type %q", ct)) + assert.Equal(t, "", rr.Header().Get("Set-Cookie"), fmt.Sprintf("no cookie should be set for Content-Type %q", ct)) + } + }) }
internal/routes/public/routes.go+7 −3 modified@@ -5,6 +5,7 @@ import ( "github.com/juice-shop/multi-juicer/internal/bundle" "github.com/juice-shop/multi-juicer/internal/metrics" + "github.com/juice-shop/multi-juicer/internal/routes/middleware" ) func AddRoutes( @@ -14,11 +15,14 @@ func AddRoutes( api := func(h http.Handler) http.Handler { return metrics.TrackRequestMetrics(metrics.RequestTypeAPIPublic, h) } + jsonAPI := func(h http.Handler) http.Handler { + return api(middleware.RequireJSONContentType(h)) + } router.Handle("/", metrics.TrackRequestMetrics(metrics.RequestTypeProxy, handleProxy(bundle))) router.Handle("GET /multi-juicer", api(redirectLoggedInTeamsToStatus(bundle, handleStaticFiles(bundle)))) router.Handle("GET /multi-juicer/", api(handleStaticFiles(bundle))) - router.Handle("POST /multi-juicer/api/teams/{team}/join", api(handleTeamJoin(bundle))) + router.Handle("POST /multi-juicer/api/teams/{team}/join", jsonAPI(handleTeamJoin(bundle))) router.Handle("POST /multi-juicer/api/teams/logout", api(handleLogout(bundle))) router.Handle("POST /multi-juicer/api/teams/reset-passcode", api(handleResetPasscode(bundle))) router.Handle("GET /multi-juicer/api/score-board/top", api(handleScoreBoard(bundle))) @@ -32,8 +36,8 @@ func AddRoutes( router.Handle("GET /multi-juicer/api/admin/all", api(requireAdmin(bundle, handleAdminListInstances(bundle)))) router.Handle("DELETE /multi-juicer/api/admin/teams/{team}/delete", api(requireAdmin(bundle, handleAdminDeleteInstance(bundle)))) router.Handle("POST /multi-juicer/api/admin/teams/{team}/restart", api(requireAdmin(bundle, handleAdminRestartInstance(bundle)))) - router.Handle("POST /multi-juicer/api/admin/notifications", api(requireAdmin(bundle, handleAdminPostNotification(bundle)))) - router.Handle("POST /multi-juicer/api/admin/clock", api(requireAdmin(bundle, handleAdminSetClock(bundle)))) + router.Handle("POST /multi-juicer/api/admin/notifications", jsonAPI(requireAdmin(bundle, handleAdminPostNotification(bundle)))) + router.Handle("POST /multi-juicer/api/admin/clock", jsonAPI(requireAdmin(bundle, handleAdminSetClock(bundle)))) router.Handle("POST /multi-juicer/api/admin/teams/{team}/reset-passcode", api(requireAdmin(bundle, handleAdminResetPasscode(bundle)))) router.HandleFunc("GET /multi-juicer/api/health", func(w http.ResponseWriter, r *http.Request) {
Vulnerability mechanics
Root cause
"Missing Content-Type validation on the team join endpoint allows cross-site form submissions to bypass CORS preflight checks."
Attack vector
An attacker hosts a cross-site HTML form that auto-submits via `enctype="text/plain"` to the MultiJuicer join endpoint [ref_id=2]. Because `text/plain` does not trigger a CORS preflight, the victim's browser sends the request without any same-origin check. The victim only needs to visit the attacker's page while having network access to the MultiJuicer deployment; no prior authentication is required [ref_id=2]. The attack plants a new session cookie rather than relying on an existing one, so `SameSite=Strict` does not mitigate it [ref_id=2].
Affected code
The team join endpoint `POST /multi-juicer/api/teams/{team}/join` accepted requests with any Content-Type, including `text/plain` [ref_id=2]. The patch adds a Content-Type validation check that rejects requests whose Content-Type is not `application/json`, returning HTTP 415 Unsupported Media Type [patch_id=6112175].
What the fix does
The patch adds a Content-Type validation check to the join endpoint (and all other JSON POST endpoints) that rejects requests whose Content-Type is not `application/json` [patch_id=6112175]. Browsers cannot set `Content-Type: application/json` on a cross-site form submission without triggering a CORS preflight, which the server would reject [ref_id=2]. The test added in the patch explicitly verifies that `text/plain`, `application/x-www-form-urlencoded`, `multipart/form-data`, and an empty Content-Type all return HTTP 415 Unsupported Media Type and do not set a session cookie [patch_id=6112175].
Preconditions
- networkVictim must have network access to the MultiJuicer deployment
- inputAttacker must know the target team name and passcode
- inputVictim must visit a page controlled by the attacker
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.