VYPR
High severity8.0NVD Advisory· Published May 21, 2026

CVE-2026-4858

CVE-2026-4858

Description

Mattermost versions 11.6.x <= 11.6.0, 11.5.x <= 11.5.3, 11.4.x <= 11.4.4, 10.11.x <= 10.11.14 fail to check integration URL for path traversal which allows an malicious authenticated user to call an arbitrary API via system admin Mattermost auth token using via path traversal in integration action URL.. Mattermost Advisory ID: MMSA-2026-00640

Affected products

1

Patches

3
436b103174af

Fixed URL validation for integration actions (#35857) (#36089)

https://github.com/mattermost/mattermostMattermost BuildApr 16, 2026Fixed in 11.4.5via llm-release-walk
2 files changed · +203 11
  • server/channels/app/integration_action.go+18 11 modified
    @@ -331,16 +331,7 @@ func (a *App) DoActionRequest(rctx request.CTX, rawURL string, body []byte) (*ht
     	req.Header.Set("Content-Type", "application/json")
     	req.Header.Set("Accept", "application/json")
     
    -	// Allow access to plugin routes for action buttons
    -	var httpClient *http.Client
    -	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    -	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    -	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(inURL.Path, path.Join(subpath, "plugins")) {
    -		req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
    -		httpClient = a.HTTPService().MakeClient(true)
    -	} else {
    -		httpClient = a.HTTPService().MakeClient(false)
    -	}
    +	httpClient := a.getPostActionClient(rctx, inURL, req)
     
     	resp, httpErr := httpClient.Do(req)
     	if httpErr != nil {
    @@ -354,6 +345,20 @@ func (a *App) DoActionRequest(rctx request.CTX, rawURL string, body []byte) (*ht
     	return resp, nil
     }
     
    +func (a *App) getPostActionClient(rctx request.CTX, inURL *url.URL, req *http.Request) *http.Client {
    +	// Allow access to plugin routes for action buttons
    +	var httpClient *http.Client
    +	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    +	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    +	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(path.Clean(inURL.Path), path.Join(subpath, "plugins")) {
    +		req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
    +		httpClient = a.HTTPService().MakeClient(true)
    +	} else {
    +		httpClient = a.HTTPService().MakeClient(false)
    +	}
    +	return httpClient
    +}
    +
     type LocalResponseWriter struct {
     	data    []byte
     	headers http.Header
    @@ -387,13 +392,15 @@ func (ch *Channels) doPluginRequest(rctx request.CTX, method, rawURL string, val
     	if err != nil {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
     	}
    -	result := strings.Split(inURL.Path, "/")
    +	result := strings.Split(path.Clean(inURL.Path), "/")
     	if len(result) < 2 {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest)
     	}
    +
     	if result[0] != "plugins" {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest)
     	}
    +
     	pluginID := result[1]
     
     	path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginID)
    
  • server/channels/app/integration_action_test.go+185 0 modified
    @@ -1621,6 +1621,105 @@ func TestDoActionRequest(t *testing.T) {
     	})
     }
     
    +func TestGetPostActionClient(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic(t)
    +
    +	tests := []struct {
    +		name       string
    +		siteURL    string
    +		subpath    string
    +		requestURL string
    +		expectAuth bool
    +	}{
    +		{
    +			name:       "same host with plugin path gets auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "same host with non-plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/v4/posts",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host with plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://evil.com/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host same port does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://attacker.com:8065/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "path traversal to reach plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/../../plugins/myplugin",
    +			expectAuth: true, // path.Clean normalizes to /plugins/myplugin
    +		},
    +		{
    +			name:       "path traversal escaping plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/../api/v4/posts",
    +			expectAuth: false, // path.Clean normalizes to /api/v4/posts
    +		},
    +		{
    +			name:       "subpath with plugin path gets auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/mattermost/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "subpath without subpath prefix does not get auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: false, // plugins path doesn't include subpath
    +		},
    +		{
    +			name:       "empty path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "plugins as query param does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api?path=plugins/myplugin",
    +			expectAuth: false,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			th.App.UpdateConfig(func(cfg *model.Config) {
    +				*cfg.ServiceSettings.SiteURL = tc.siteURL
    +			})
    +
    +			inURL, err := url.Parse(tc.requestURL)
    +			require.NoError(t, err)
    +
    +			req, err := http.NewRequest("POST", tc.requestURL, nil)
    +			require.NoError(t, err)
    +
    +			_ = th.App.getPostActionClient(th.Context, inURL, req)
    +
    +			if tc.expectAuth {
    +				assert.NotEmpty(t, req.Header.Get(model.HeaderAuth), "expected auth header to be set")
    +				assert.Contains(t, req.Header.Get(model.HeaderAuth), "Bearer ")
    +			} else {
    +				assert.Empty(t, req.Header.Get(model.HeaderAuth), "expected no auth header")
    +			}
    +		})
    +	}
    +}
    +
     func TestDoLocalRequest(t *testing.T) {
     	mainHelper.Parallel(t)
     	th := Setup(t).InitBasic(t)
    @@ -1816,4 +1915,90 @@ func TestDoPluginRequest(t *testing.T) {
     	require.NotNil(t, resp)
     	body, _ = io.ReadAll(resp.Body)
     	assert.Equal(t, "param multiple not correct", string(body))
    +
    +	t.Run("should handle URLs with path traversals", func(t *testing.T) {
    +		tests := []struct {
    +			name      string
    +			rawURL    string
    +			expectErr bool
    +			errDetail string
    +		}{
    +			{
    +				name:      "path traversal to escape plugins directory",
    +				rawURL:    "/plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal with encoded slashes",
    +				rawURL:    "/plugins/..%2F..%2F..%2Fetc%2Fpasswd",
    +				expectErr: true, // url.Parse decodes %2F, path.Clean normalizes traversal
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "double dot in plugin path",
    +				rawURL:    "/plugins/../plugins/myplugin/action",
    +				expectErr: false, // path.Clean normalizes this back to plugins/myplugin/action
    +			},
    +			{
    +				name:      "path traversal without leading slash",
    +				rawURL:    "plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "only plugins with no plugin ID",
    +				rawURL:    "/plugins/",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "just plugins no trailing slash",
    +				rawURL:    "/plugins",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "non-plugins path",
    +				rawURL:    "/api/v4/users",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal via dot segments after plugin ID",
    +				rawURL:    "/plugins/myplugin/../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "backslash traversal attempt",
    +				rawURL:    "/plugins/myplugin/..\\..\\etc\\passwd",
    +				expectErr: false, // backslashes are not path separators in URL paths; treated as literal
    +			},
    +			{
    +				name:      "null byte injection attempt",
    +				rawURL:    "/plugins/myplugin\x00/action",
    +				expectErr: true, // url.Parse rejects URLs with null bytes
    +			},
    +		}
    +
    +		for _, tc := range tests {
    +			t.Run(tc.name, func(t *testing.T) {
    +				resp, appErr := th.App.doPluginRequest(th.Context, "GET", tc.rawURL, nil, nil)
    +				if tc.expectErr {
    +					require.NotNil(t, appErr, "expected error for URL: %s", tc.rawURL)
    +					if tc.errDetail != "" {
    +						assert.Contains(t, appErr.DetailedError, tc.errDetail)
    +					}
    +				} else {
    +					// Should not return an app error from path validation;
    +					// may still get a 404 if the plugin doesn't exist, which is fine.
    +					assert.Nil(t, appErr, "unexpected error for URL: %s - %v", tc.rawURL, appErr)
    +					if resp != nil {
    +						resp.Body.Close()
    +					}
    +				}
    +			})
    +		}
    +	})
     }
    
7526844c5052

Fixed URL validation for integration actions (#35857) (#36108)

https://github.com/mattermost/mattermostHarshil SharmaApr 15, 2026Fixed in 10.11.15via llm-release-walk
2 files changed · +203 11
  • server/channels/app/integration_action.go+18 11 modified
    @@ -331,16 +331,7 @@ func (a *App) DoActionRequest(c request.CTX, rawURL string, body []byte) (*http.
     	req.Header.Set("Content-Type", "application/json")
     	req.Header.Set("Accept", "application/json")
     
    -	// Allow access to plugin routes for action buttons
    -	var httpClient *http.Client
    -	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    -	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    -	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(inURL.Path, path.Join(subpath, "plugins")) {
    -		req.Header.Set(model.HeaderAuth, "Bearer "+c.Session().Token)
    -		httpClient = a.HTTPService().MakeClient(true)
    -	} else {
    -		httpClient = a.HTTPService().MakeClient(false)
    -	}
    +	httpClient := a.getPostActionClient(c, inURL, req)
     
     	resp, httpErr := httpClient.Do(req)
     	if httpErr != nil {
    @@ -354,6 +345,20 @@ func (a *App) DoActionRequest(c request.CTX, rawURL string, body []byte) (*http.
     	return resp, nil
     }
     
    +func (a *App) getPostActionClient(rctx request.CTX, inURL *url.URL, req *http.Request) *http.Client {
    +	// Allow access to plugin routes for action buttons
    +	var httpClient *http.Client
    +	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    +	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    +	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(path.Clean(inURL.Path), path.Join(subpath, "plugins")) {
    +		req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
    +		httpClient = a.HTTPService().MakeClient(true)
    +	} else {
    +		httpClient = a.HTTPService().MakeClient(false)
    +	}
    +	return httpClient
    +}
    +
     type LocalResponseWriter struct {
     	data    []byte
     	headers http.Header
    @@ -387,13 +392,15 @@ func (ch *Channels) doPluginRequest(c request.CTX, method, rawURL string, values
     	if err != nil {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
     	}
    -	result := strings.Split(inURL.Path, "/")
    +	result := strings.Split(path.Clean(inURL.Path), "/")
     	if len(result) < 2 {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest)
     	}
    +
     	if result[0] != "plugins" {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest)
     	}
    +
     	pluginID := result[1]
     
     	path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginID)
    
  • server/channels/app/integration_action_test.go+185 0 modified
    @@ -1198,6 +1198,105 @@ func TestPostActionRelativePluginURL(t *testing.T) {
     	})
     }
     
    +func TestGetPostActionClient(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	tests := []struct {
    +		name       string
    +		siteURL    string
    +		subpath    string
    +		requestURL string
    +		expectAuth bool
    +	}{
    +		{
    +			name:       "same host with plugin path gets auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "same host with non-plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/v4/posts",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host with plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://evil.com/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host same port does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://attacker.com:8065/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "path traversal to reach plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/../../plugins/myplugin",
    +			expectAuth: true, // path.Clean normalizes to /plugins/myplugin
    +		},
    +		{
    +			name:       "path traversal escaping plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/../api/v4/posts",
    +			expectAuth: false, // path.Clean normalizes to /api/v4/posts
    +		},
    +		{
    +			name:       "subpath with plugin path gets auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/mattermost/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "subpath without subpath prefix does not get auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: false, // plugins path doesn't include subpath
    +		},
    +		{
    +			name:       "empty path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "plugins as query param does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api?path=plugins/myplugin",
    +			expectAuth: false,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			th.App.UpdateConfig(func(cfg *model.Config) {
    +				*cfg.ServiceSettings.SiteURL = tc.siteURL
    +			})
    +
    +			inURL, err := url.Parse(tc.requestURL)
    +			require.NoError(t, err)
    +
    +			req, err := http.NewRequest("POST", tc.requestURL, nil)
    +			require.NoError(t, err)
    +
    +			_ = th.App.getPostActionClient(th.Context, inURL, req)
    +
    +			if tc.expectAuth {
    +				assert.NotEmpty(t, req.Header.Get(model.HeaderAuth), "expected auth header to be set")
    +				assert.Contains(t, req.Header.Get(model.HeaderAuth), "Bearer ")
    +			} else {
    +				assert.Empty(t, req.Header.Get(model.HeaderAuth), "expected no auth header")
    +			}
    +		})
    +	}
    +}
    +
     func TestDoPluginRequest(t *testing.T) {
     	mainHelper.Parallel(t)
     	th := Setup(t)
    @@ -1303,4 +1402,90 @@ func TestDoPluginRequest(t *testing.T) {
     	require.NotNil(t, resp)
     	body, _ = io.ReadAll(resp.Body)
     	assert.Equal(t, "param multiple not correct", string(body))
    +
    +	t.Run("should handle URLs with path traversals", func(t *testing.T) {
    +		tests := []struct {
    +			name      string
    +			rawURL    string
    +			expectErr bool
    +			errDetail string
    +		}{
    +			{
    +				name:      "path traversal to escape plugins directory",
    +				rawURL:    "/plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal with encoded slashes",
    +				rawURL:    "/plugins/..%2F..%2F..%2Fetc%2Fpasswd",
    +				expectErr: true, // url.Parse decodes %2F, path.Clean normalizes traversal
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "double dot in plugin path",
    +				rawURL:    "/plugins/../plugins/myplugin/action",
    +				expectErr: false, // path.Clean normalizes this back to plugins/myplugin/action
    +			},
    +			{
    +				name:      "path traversal without leading slash",
    +				rawURL:    "plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "only plugins with no plugin ID",
    +				rawURL:    "/plugins/",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "just plugins no trailing slash",
    +				rawURL:    "/plugins",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "non-plugins path",
    +				rawURL:    "/api/v4/users",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal via dot segments after plugin ID",
    +				rawURL:    "/plugins/myplugin/../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "backslash traversal attempt",
    +				rawURL:    "/plugins/myplugin/..\\..\\etc\\passwd",
    +				expectErr: false, // backslashes are not path separators in URL paths; treated as literal
    +			},
    +			{
    +				name:      "null byte injection attempt",
    +				rawURL:    "/plugins/myplugin\x00/action",
    +				expectErr: true, // url.Parse rejects URLs with null bytes
    +			},
    +		}
    +
    +		for _, tc := range tests {
    +			t.Run(tc.name, func(t *testing.T) {
    +				resp, appErr := th.App.doPluginRequest(th.Context, "GET", tc.rawURL, nil, nil)
    +				if tc.expectErr {
    +					require.NotNil(t, appErr, "expected error for URL: %s", tc.rawURL)
    +					if tc.errDetail != "" {
    +						assert.Contains(t, appErr.DetailedError, tc.errDetail)
    +					}
    +				} else {
    +					// Should not return an app error from path validation;
    +					// may still get a 404 if the plugin doesn't exist, which is fine.
    +					assert.Nil(t, appErr, "unexpected error for URL: %s - %v", tc.rawURL, appErr)
    +					if resp != nil {
    +						resp.Body.Close()
    +					}
    +				}
    +			})
    +		}
    +	})
     }
    
d160c7df48e9

Fixed URL validation for integration actions (#35857) (#36021)

https://github.com/mattermost/mattermostMattermost BuildApr 10, 2026Fixed in 11.6.1via llm-release-walk
2 files changed · +203 11
  • server/channels/app/integration_action.go+18 11 modified
    @@ -331,16 +331,7 @@ func (a *App) DoActionRequest(rctx request.CTX, rawURL string, body []byte) (*ht
     	req.Header.Set("Content-Type", "application/json")
     	req.Header.Set("Accept", "application/json")
     
    -	// Allow access to plugin routes for action buttons
    -	var httpClient *http.Client
    -	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    -	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    -	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(inURL.Path, path.Join(subpath, "plugins")) {
    -		req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
    -		httpClient = a.HTTPService().MakeClient(true)
    -	} else {
    -		httpClient = a.HTTPService().MakeClient(false)
    -	}
    +	httpClient := a.getPostActionClient(rctx, inURL, req)
     
     	resp, httpErr := httpClient.Do(req)
     	if httpErr != nil {
    @@ -354,6 +345,20 @@ func (a *App) DoActionRequest(rctx request.CTX, rawURL string, body []byte) (*ht
     	return resp, nil
     }
     
    +func (a *App) getPostActionClient(rctx request.CTX, inURL *url.URL, req *http.Request) *http.Client {
    +	// Allow access to plugin routes for action buttons
    +	var httpClient *http.Client
    +	subpath, _ := utils.GetSubpathFromConfig(a.Config())
    +	siteURL, _ := url.Parse(*a.Config().ServiceSettings.SiteURL)
    +	if inURL.Hostname() == siteURL.Hostname() && strings.HasPrefix(path.Clean(inURL.Path), path.Join(subpath, "plugins")) {
    +		req.Header.Set(model.HeaderAuth, "Bearer "+rctx.Session().Token)
    +		httpClient = a.HTTPService().MakeClient(true)
    +	} else {
    +		httpClient = a.HTTPService().MakeClient(false)
    +	}
    +	return httpClient
    +}
    +
     type LocalResponseWriter struct {
     	data    []byte
     	headers http.Header
    @@ -387,13 +392,15 @@ func (ch *Channels) doPluginRequest(rctx request.CTX, method, rawURL string, val
     	if err != nil {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest).Wrap(err)
     	}
    -	result := strings.Split(inURL.Path, "/")
    +	result := strings.Split(path.Clean(inURL.Path), "/")
     	if len(result) < 2 {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=Unable to find pluginId", http.StatusBadRequest)
     	}
    +
     	if result[0] != "plugins" {
     		return nil, model.NewAppError("doPluginRequest", "api.post.do_action.action_integration.app_error", nil, "err=plugins not in path", http.StatusBadRequest)
     	}
    +
     	pluginID := result[1]
     
     	path := strings.TrimPrefix(inURL.Path, "plugins/"+pluginID)
    
  • server/channels/app/integration_action_test.go+185 0 modified
    @@ -1621,6 +1621,105 @@ func TestDoActionRequest(t *testing.T) {
     	})
     }
     
    +func TestGetPostActionClient(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic(t)
    +
    +	tests := []struct {
    +		name       string
    +		siteURL    string
    +		subpath    string
    +		requestURL string
    +		expectAuth bool
    +	}{
    +		{
    +			name:       "same host with plugin path gets auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "same host with non-plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/v4/posts",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host with plugin path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://evil.com/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "different host same port does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://attacker.com:8065/plugins/myplugin/action",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "path traversal to reach plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api/../../plugins/myplugin",
    +			expectAuth: true, // path.Clean normalizes to /plugins/myplugin
    +		},
    +		{
    +			name:       "path traversal escaping plugins does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/plugins/../api/v4/posts",
    +			expectAuth: false, // path.Clean normalizes to /api/v4/posts
    +		},
    +		{
    +			name:       "subpath with plugin path gets auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/mattermost/plugins/myplugin/action",
    +			expectAuth: true,
    +		},
    +		{
    +			name:       "subpath without subpath prefix does not get auth",
    +			siteURL:    "http://localhost:8065/mattermost",
    +			subpath:    "/mattermost",
    +			requestURL: "http://localhost:8065/plugins/myplugin/action",
    +			expectAuth: false, // plugins path doesn't include subpath
    +		},
    +		{
    +			name:       "empty path does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/",
    +			expectAuth: false,
    +		},
    +		{
    +			name:       "plugins as query param does not get auth",
    +			siteURL:    "http://localhost:8065",
    +			requestURL: "http://localhost:8065/api?path=plugins/myplugin",
    +			expectAuth: false,
    +		},
    +	}
    +
    +	for _, tc := range tests {
    +		t.Run(tc.name, func(t *testing.T) {
    +			th.App.UpdateConfig(func(cfg *model.Config) {
    +				*cfg.ServiceSettings.SiteURL = tc.siteURL
    +			})
    +
    +			inURL, err := url.Parse(tc.requestURL)
    +			require.NoError(t, err)
    +
    +			req, err := http.NewRequest("POST", tc.requestURL, nil)
    +			require.NoError(t, err)
    +
    +			_ = th.App.getPostActionClient(th.Context, inURL, req)
    +
    +			if tc.expectAuth {
    +				assert.NotEmpty(t, req.Header.Get(model.HeaderAuth), "expected auth header to be set")
    +				assert.Contains(t, req.Header.Get(model.HeaderAuth), "Bearer ")
    +			} else {
    +				assert.Empty(t, req.Header.Get(model.HeaderAuth), "expected no auth header")
    +			}
    +		})
    +	}
    +}
    +
     func TestDoLocalRequest(t *testing.T) {
     	mainHelper.Parallel(t)
     	th := Setup(t).InitBasic(t)
    @@ -1816,4 +1915,90 @@ func TestDoPluginRequest(t *testing.T) {
     	require.NotNil(t, resp)
     	body, _ = io.ReadAll(resp.Body)
     	assert.Equal(t, "param multiple not correct", string(body))
    +
    +	t.Run("should handle URLs with path traversals", func(t *testing.T) {
    +		tests := []struct {
    +			name      string
    +			rawURL    string
    +			expectErr bool
    +			errDetail string
    +		}{
    +			{
    +				name:      "path traversal to escape plugins directory",
    +				rawURL:    "/plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal with encoded slashes",
    +				rawURL:    "/plugins/..%2F..%2F..%2Fetc%2Fpasswd",
    +				expectErr: true, // url.Parse decodes %2F, path.Clean normalizes traversal
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "double dot in plugin path",
    +				rawURL:    "/plugins/../plugins/myplugin/action",
    +				expectErr: false, // path.Clean normalizes this back to plugins/myplugin/action
    +			},
    +			{
    +				name:      "path traversal without leading slash",
    +				rawURL:    "plugins/../../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "only plugins with no plugin ID",
    +				rawURL:    "/plugins/",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "just plugins no trailing slash",
    +				rawURL:    "/plugins",
    +				expectErr: true,
    +				errDetail: "Unable to find pluginId",
    +			},
    +			{
    +				name:      "non-plugins path",
    +				rawURL:    "/api/v4/users",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "path traversal via dot segments after plugin ID",
    +				rawURL:    "/plugins/myplugin/../../etc/passwd",
    +				expectErr: true,
    +				errDetail: "plugins not in path",
    +			},
    +			{
    +				name:      "backslash traversal attempt",
    +				rawURL:    "/plugins/myplugin/..\\..\\etc\\passwd",
    +				expectErr: false, // backslashes are not path separators in URL paths; treated as literal
    +			},
    +			{
    +				name:      "null byte injection attempt",
    +				rawURL:    "/plugins/myplugin\x00/action",
    +				expectErr: true, // url.Parse rejects URLs with null bytes
    +			},
    +		}
    +
    +		for _, tc := range tests {
    +			t.Run(tc.name, func(t *testing.T) {
    +				resp, appErr := th.App.doPluginRequest(th.Context, "GET", tc.rawURL, nil, nil)
    +				if tc.expectErr {
    +					require.NotNil(t, appErr, "expected error for URL: %s", tc.rawURL)
    +					if tc.errDetail != "" {
    +						assert.Contains(t, appErr.DetailedError, tc.errDetail)
    +					}
    +				} else {
    +					// Should not return an app error from path validation;
    +					// may still get a 404 if the plugin doesn't exist, which is fine.
    +					assert.Nil(t, appErr, "unexpected error for URL: %s - %v", tc.rawURL, appErr)
    +					if resp != nil {
    +						resp.Body.Close()
    +					}
    +				}
    +			})
    +		}
    +	})
     }
    

Vulnerability mechanics

Root cause

"Missing validation of integration action URLs allows path traversal, enabling an attacker to forge API calls using a system admin's auth token."

Attack vector

An authenticated attacker with permission to create or modify integrations crafts an integration action URL containing path traversal sequences (e.g., `../`). When the integration is triggered, the server fails to validate the URL and follows the traversal, making an arbitrary API request. Because the request is processed server-side, it inherits the auth token of the system admin who configured the integration, bypassing the attacker's own permissions. The attack requires the victim to have an integration with an action URL that the attacker can control or influence [patch_id=1165700][patch_id=1167577][patch_id=1168083].

Affected code

The vulnerability exists in the integration action URL handling logic within Mattermost server code. The patches modify the code paths that process outgoing HTTP requests triggered by integration actions, adding validation to prevent path traversal. The specific files are not detailed in the advisory, but the patches are located in the Mattermost repository at the referenced commits [patch_id=1165700][patch_id=1167577][patch_id=1168083].

What the fix does

The patches add validation to check that integration action URLs do not contain path traversal sequences before the request is dispatched. The fix ensures that URLs are normalized and rejected if they attempt to escape the expected base path. By preventing traversal, the server no longer forwards the system admin's auth token to unintended API endpoints [patch_id=1165700][patch_id=1167577][patch_id=1168083].

Preconditions

  • authAttacker must be an authenticated Mattermost user.
  • inputAttacker must be able to create or modify an integration with a controllable action URL.
  • configA system admin must have configured the integration, making their auth token available server-side for the request.

Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.