VYPR
Medium severity4.3NVD Advisory· Published Jun 15, 2026

CVE-2026-48518

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

Patches

1
75b08c3fdda4

Validate content-type header for JSON endpoints

https://github.com/juice-shop/multi-juicerJannik HollenbachMay 21, 2026via nvd-ref
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

3

News mentions

0

No linked articles in our index yet.