Repository: elastic/apm-server
Pull request #15976: Change server information endpoint `/` to only accept GET and HEAD requests
Author: login: carsonip
Labels: name: test-plan
name: test-plan-ok
name: backport-active-9
description: Automated backport with mergify to all the active 9.[0-9]+ branches
name: v9.0.0
Created: 2025-03-03T16:17:02Z | Merged: 2025-03-05T14:19:49Z

<!-- Thanks for sending a pull request!

If this is your first contribution, please review and sign our contributor agreement -
https://www.elastic.co/contributor-agreement.

Guidelines:
 - Prefer small PRs, and split changes into multiple logical commits where they must
   be delivered in a single PR.
 - If the PR is incomplete and not yet ready for review, open it as a Draft.
 - Once the PR is marked ready for review it is expected to pass all tests and linting,
   and you should not force-push any changes.

See also https://github.com/elastic/apm-server/blob/main/CONTRIBUTING.md for more tips on contributing.
-->

## Motivation/summary

<!--
Describe your change in the title and description, and provide a motivation for the
change and rationale for the approach taken.
-->
Breaking change to change server information endpoint / to only accept GET and HEAD requests, and return 405 Method Not Allowed otherwise. This will surface any agent misconfiguration, e.g. configuring otlphttp to send to / instead of /v1/traces.

## Checklist

<!--
Delete irrelevant items. The changelog should only be updated for user-facing changes.
Once the PR is ready for review there should be no unticked boxes.
-->

- [x] Update [CHANGELOG.asciidoc](https://github.com/elastic/apm-server/blob/main/CHANGELOG.asciidoc)
- [x] Documentation has been updated: https://github.com/elastic/docs-content/pull/647

For functional changes, consider:
- Is it observable through the addition of either **logging** or **metrics**?
- Is its use being published in **telemetry** to enable product improvement?
- Have system tests been added to avoid regression?

## How to test these changes

<!--
Explain how this PR can be tested by the reviewer: commands, dependencies, steps, etc.
If it is self-explanatory, delete this section.
-->
```shell
curl -XGET -v http://apm-server/
curl -XHEAD -v http://apm-server/
curl -XPOST -v http://apm-server/
```

Confirm that GET and HEAD requests return 200, POST requests return 405.

## Related issues

<!--
Reference the related issue(s), and make use of magic keywords where it makes sense
https://help.github.com/articles/closing-issues-using-keywords/.
-->
Fixes #15965


Linked issue #15965: Return an error on POST requests to `/`
From https://discuss.elastic.co/t/not-seeing-logs-from-otel-collector-in-apm/375275

As apm-server accepts HTTP GET and POST (and possibly more) to path `/`, it is easy for users to misconfigure otlphttp exporter to send to `http://apm-server/` instead of `http://apm-server/v1/traces`. Even worse, because the endpoint always return 200, any otlphttp misconfiguration would fail silently, causing a poor user experience.

```
carson@carson-elastic:~$ curl localhost:8200/
{
  "build_date": "0001-01-01T00:00:00Z",
  "build_sha": "unknown",
  "publish_ready": false,
  "version": "8.19.0"
}
carson@carson-elastic:~$ curl -XPOST localhost:8200/
{
  "build_date": "0001-01-01T00:00:00Z",
  "build_sha": "unknown",
  "publish_ready": false,
  "version": "8.19.0"
}
```

Proposed fix: return 405 (or any 4xx) for POST requests to `/`, and possibly include a helpful error message, asking if the user wants to send to `POST /intake/v2/events` or `POST /v1/*` otlphttp endpoints instead.
Linked issue comments: user: login: kruskall
created_at: 2025-03-03T15:09:24Z
body: > As apm-server accepts HTTP GET and POST (and possibly more) to path /

This is explicitly documented and the proposed fix is a non trivial breaking change, we can't do this unless we decide to include it in te 9.x breaking changes.
author_association: MEMBER
user: login: simitt
created_at: 2025-03-03T15:38:56Z
body: >a non trivial breaking change

@kruskall what is the complexity that you are worried about? IMO it should be fairly straightfoward to add something like the following to the [root.Handler](https://github.com/elastic/apm-server/blob/main/internal/beater/api/root/handler.go#L44-L58)
```
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
    return errMethodNotAllowed
}
```

This was an oversight to not fix it in `9.0` and I'd be +1 on adding this to `9.0.0`. 
author_association: CONTRIBUTOR
user: login: kruskall
created_at: 2025-03-03T15:41:58Z
body: The code change is trivial but imo the change in behaviour is not which is why we should do it in a major version.

the version endpoint is also used by kubernetes for readiness/healthness probe
author_association: MEMBER

Diff:
diff --git a/docs/spec/openapi/paths/server_info.yaml b/docs/spec/openapi/paths/server_info.yaml
index a70149acfce..9a4a5365422 100644
--- a/docs/spec/openapi/paths/server_info.yaml
+++ b/docs/spec/openapi/paths/server_info.yaml
@@ -15,11 +15,3 @@ get:
   responses:
     '200':
       $ref: '../components/responses/200_server_info.yaml'
-post:
-  summary: Get general server information
-  operationId: postServerHealth
-  tags:
-    - server info
-  responses:
-    '200':
-      $ref: '../components/responses/200_server_info.yaml'
diff --git a/internal/beater/api/mux_root_test.go b/internal/beater/api/mux_root_test.go
index 2b2af7dcc47..5c814786abd 100644
--- a/internal/beater/api/mux_root_test.go
+++ b/internal/beater/api/mux_root_test.go
@@ -20,6 +20,7 @@ package api
 import (
 	"encoding/json"
 	"net/http"
+	"net/http/httptest"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -27,6 +28,7 @@ import (
 
 	"github.com/elastic/apm-server/internal/beater/config"
 	"github.com/elastic/apm-server/internal/beater/headers"
+	"github.com/elastic/apm-server/internal/beater/monitoringtest"
 	"github.com/elastic/apm-server/internal/beater/request"
 	"github.com/elastic/apm-server/internal/version"
 )
@@ -36,7 +38,7 @@ func TestRootHandler_AuthorizationMiddleware(t *testing.T) {
 	cfg.AgentAuth.SecretToken = "1234"
 
 	t.Run("No auth", func(t *testing.T) {
-		rec, err := requestToMuxerWithPattern(cfg, RootPath)
+		rec, err := requestToMuxerWithHeader(cfg, RootPath, http.MethodGet, nil)
 		require.NoError(t, err)
 		assert.Equal(t, http.StatusOK, rec.Code)
 		assert.Empty(t, rec.Body.String())
@@ -63,10 +65,53 @@ func TestRootHandler_PanicMiddleware(t *testing.T) {
 }
 
 func TestRootHandler_MonitoringMiddleware(t *testing.T) {
-	testMonitoringMiddleware(t, "/", map[string]any{
-		"http.server." + string(request.IDRequestCount):       1,
-		"http.server." + string(request.IDResponseCount):      1,
-		"http.server." + string(request.IDResponseValidCount): 1,
-		"http.server." + string(request.IDResponseValidOK):    1,
-	})
+	validMethodMetrics := map[string]any{
+		"http.server." + string(request.IDRequestCount):           1,
+		"http.server." + string(request.IDResponseCount):          1,
+		"http.server." + string(request.IDResponseValidCount):     1,
+		"http.server." + string(request.IDResponseValidOK):        1,
+		"apm-server.root." + string(request.IDRequestCount):       1,
+		"apm-server.root." + string(request.IDResponseCount):      1,
+		"apm-server.root." + string(request.IDResponseValidCount): 1,
+		"apm-server.root." + string(request.IDResponseValidOK):    1,
+	}
+	invalidMethodMetrics := map[string]any{
+		"http.server." + string(request.IDRequestCount):                       1,
+		"http.server." + string(request.IDResponseCount):                      1,
+		"http.server." + string(request.IDResponseErrorsCount):                1,
+		"http.server." + string(request.IDResponseErrorsMethodNotAllowed):     1,
+		"apm-server.root." + string(request.IDRequestCount):                   1,
+		"apm-server.root." + string(request.IDResponseCount):                  1,
+		"apm-server.root." + string(request.IDResponseErrorsCount):            1,
+		"apm-server.root." + string(request.IDResponseErrorsMethodNotAllowed): 1,
+	}
+	for _, tc := range []struct {
+		method      string
+		wantMetrics map[string]any
+	}{
+		{
+			method:      http.MethodGet,
+			wantMetrics: validMethodMetrics,
+		},
+		{
+			method:      http.MethodHead,
+			wantMetrics: validMethodMetrics,
+		},
+		{
+			method:      http.MethodPut,
+			wantMetrics: invalidMethodMetrics,
+		},
+		{
+			method:      http.MethodPost,
+			wantMetrics: invalidMethodMetrics,
+		},
+	} {
+		t.Run(tc.method, func(t *testing.T) {
+			h, reader := newTestMux(t, config.DefaultConfig())
+			req := httptest.NewRequest(tc.method, "/", nil)
+			h.ServeHTTP(httptest.NewRecorder(), req)
+
+			monitoringtest.ExpectContainOtelMetrics(t, reader, tc.wantMetrics)
+		})
+	}
 }
diff --git a/internal/beater/api/root/handler.go b/internal/beater/api/root/handler.go
index 2ef21a67bc4..adce51c3421 100644
--- a/internal/beater/api/root/handler.go
+++ b/internal/beater/api/root/handler.go
@@ -18,6 +18,8 @@
 package root
 
 import (
+	"errors"
+	"net/http"
 	"time"
 
 	"github.com/elastic/beats/v7/libbeat/version"
@@ -38,10 +40,21 @@ type HandlerConfig struct {
 
 // Handler returns error if route does not exist,
 // otherwise returns information about the server. The detail level differs for authenticated and anonymous requests.
-// TODO: only allow GET, HEAD requests (breaking change)
 func Handler(cfg HandlerConfig) request.Handler {
-
 	return func(c *request.Context) {
+		// Only allow GET, HEAD requests
+		switch c.Request.Method {
+		case http.MethodGet, http.MethodHead:
+		default:
+			c.Result.SetWithError(
+				request.IDResponseErrorsMethodNotAllowed,
+				// include a verbose error message to alert users about a common misconfiguration
+				errors.New("this is the server information endpoint; did you mean to send data to another endpoint instead?"),
+			)
+			c.WriteResult()
+			return
+		}
+
 		serverInfo := mapstr.M{
 			"build_date": version.BuildTime().Format(time.RFC3339),
 			"build_sha":  version.Commit(),


Reviews:
user: login: carsonip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T16:36:50Z
user: login: carsonip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-03T16:49:26Z
user: login: rubvs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T00:02:31Z
user: login: 1pkg
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T00:45:03Z
user: login: endorama
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T07:53:15Z
user: login: endorama
body: Overall good to me, thanks Carson!
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T07:53:26Z
user: login: carsonip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T10:45:31Z
user: login: carsonip
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T10:47:01Z
user: login: kruskall
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-04T13:02:49Z
user: login: simitt
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:45:20Z
user: login: simitt
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T13:50:26Z

Review comments:
diff_hunk: @@ -38,10 +40,21 @@ type HandlerConfig struct {
 
 // Handler returns error if route does not exist,
 // otherwise returns information about the server. The detail level differs for authenticated and anonymous requests.
-// TODO: only allow GET, HEAD requests (breaking change)
 func Handler(cfg HandlerConfig) request.Handler {
-
 	return func(c *request.Context) {
+		// Only allow GET, HEAD requests
+		switch c.Request.Method {
+		case http.MethodGet, http.MethodHead:
+		default:
+			c.Result.SetWithError(
+				request.IDResponseErrorsMethodNotAllowed,
+				// include a verbose error message to alert users about a common misconfiguration
+				errors.New("this is the health check endpoint; did you mean to send data to another endpoint instead?"),
path: internal/beater/api/root/handler.go
user: login: carsonip
body: [to reviewer] lmk if this is too verbose, I can revert the commit.

```
$ curl -XPOST -v localhost:8200/
* Host localhost:8200 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8200...
* connect to ::1 port 8200 from ::1 port 49848 failed: Connection refused
*   Trying 127.0.0.1:8200...
* Connected to localhost (127.0.0.1) port 8200
> POST / HTTP/1.1
> Host: localhost:8200
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 405 Method Not Allowed
< Content-Type: application/json
< X-Content-Type-Options: nosniff
< Date: Mon, 03 Mar 2025 16:35:38 GMT
< Content-Length: 129
< 
{
  "error": "method not supported: this is the health check endpoint; did you mean to send data to another endpoint instead?"
}
* Connection #0 to host localhost left intact
```
created_at: 2025-03-03T16:36:40Z
author_association: MEMBER
diff_hunk: @@ -15,11 +15,3 @@ get:
   responses:
     '200':
       $ref: '../components/responses/200_server_info.yaml'
-post:
path: docs/spec/openapi/paths/server_info.yaml
user: login: carsonip
body: [to reviewer] other than openapi spec, we'll also need to remove it from obs docs in a separate PR: https://www.elastic.co/guide/en/observability/current/apm-api-info.html
created_at: 2025-03-03T16:49:12Z
author_association: MEMBER
diff_hunk: @@ -38,10 +40,21 @@ type HandlerConfig struct {
 
 // Handler returns error if route does not exist,
 // otherwise returns information about the server. The detail level differs for authenticated and anonymous requests.
-// TODO: only allow GET, HEAD requests (breaking change)
 func Handler(cfg HandlerConfig) request.Handler {
-
 	return func(c *request.Context) {
+		// Only allow GET, HEAD requests
+		switch c.Request.Method {
+		case http.MethodGet, http.MethodHead:
+		default:
+			c.Result.SetWithError(
+				request.IDResponseErrorsMethodNotAllowed,
+				// include a verbose error message to alert users about a common misconfiguration
+				errors.New("this is the health check endpoint; did you mean to send data to another endpoint instead?"),
path: internal/beater/api/root/handler.go
user: login: rubvs
body: I think it's fine. 
created_at: 2025-03-04T00:02:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,11 +15,3 @@ get:
   responses:
     '200':
       $ref: '../components/responses/200_server_info.yaml'
-post:
path: docs/spec/openapi/paths/server_info.yaml
user: login: endorama
body: Why would you remove it? I would add a disclaimer that the `/` endpoint is to be used fr healthcheck or test only, but I think the use case of POST with credentials (to verify credentials) is a nice one to give our user guidance on. I would definitely clarify its purpose though.
created_at: 2025-03-04T07:53:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,11 +15,3 @@ get:
   responses:
     '200':
       $ref: '../components/responses/200_server_info.yaml'
-post:
path: docs/spec/openapi/paths/server_info.yaml
user: login: carsonip
body: the intention of this very PR is to remove the "functionality" to POST to `/`. As such, we are removing the corresponding part in the docs. If we don't remove it in the docs, we'll be left with docs that reference an endpoint that doesn't work.
created_at: 2025-03-04T10:45:31Z
author_association: MEMBER
diff_hunk: @@ -15,11 +15,3 @@ get:
   responses:
     '200':
       $ref: '../components/responses/200_server_info.yaml'
-post:
path: docs/spec/openapi/paths/server_info.yaml
user: login: carsonip
body: > I think the use case of POST with credentials (to verify credentials) is a nice one to give our user guidance on

I agree that we should update the docs to state that we can optionally provide credentials on the GET request to get additional info about the server. But I'm not sure if OpenAPI spec supports that.
created_at: 2025-03-04T10:47:00Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-03T16:17:22Z
body: > [!WARNING]
> It looks like this PR modifies one or more `.asciidoc` files. These files are being migrated to Markdown, and any changes merged now will be lost. See the [migration guide](https://elastic.github.io/docs-builder/migration/freeze/index.html) for details.
author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-03-03T16:17:49Z
body: This pull request does not have a backport label. Could you fix it @carsonip? 🙏
To fixup this pull request, you need to add the backport labels for the needed
branches, such as:
* `backport-7.17` is the label to automatically backport to the 7.17 branch.
* `backport-8./d` is the label to automatically backport to the `8./d` branch. `/d` is the digit.
* `backport-9./d` is the label to automatically backport to the `9./d` branch. `/d` is the digit.
* `backport-8.x` is the label to automatically backport to the `8.x` branch.
* `backport-active-all` is the label that automatically backports to all active branches.
* `backport-active-8` is the label that automatically backports to all active minor branches for the 8 major.
* `backport-active-9` is the label that automatically backports to all active minor branches for the 9 major.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T16:36:27Z
body: > [!WARNING]
> It looks like this PR modifies one or more `.asciidoc` files. These files are being migrated to Markdown, and any changes merged now will be lost. See the [migration guide](https://elastic.github.io/docs-builder/migration/freeze/index.html) for details.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T16:40:58Z
body: > [!WARNING]
> It looks like this PR modifies one or more `.asciidoc` files. These files are being migrated to Markdown, and any changes merged now will be lost. See the [migration guide](https://elastic.github.io/docs-builder/migration/freeze/index.html) for details.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T16:48:39Z
body: > [!WARNING]
> It looks like this PR modifies one or more `.asciidoc` files. These files are being migrated to Markdown, and any changes merged now will be lost. See the [migration guide](https://elastic.github.io/docs-builder/migration/freeze/index.html) for details.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-03T16:55:15Z
body: > [!WARNING]
> It looks like this PR modifies one or more `.asciidoc` files. These files are being migrated to Markdown, and any changes merged now will be lost. See the [migration guide](https://elastic.github.io/docs-builder/migration/freeze/index.html) for details.
author_association: CONTRIBUTOR
user: login: rubvs
created_at: 2025-06-18T16:14:21Z
body: ## Test Plan for v9.1.0


- Commit BEFORE change: `git checkout b17bf3460`
- Spin up local APM Server
```shell
> curl -X GET -v http://localhost:8200/
```
```json
{
  "log.level": "info",
  "@timestamp": "2025-06-18T12:05:54.958-0400",
  "log.logger": "request",
  "log.origin": {
    "function": "github.com/elastic/apm-server/internal/beater/api.apmMiddleware.LogMiddleware.func1.1",
    "file.name": "middleware/log_middleware.go",
    "file.line": 62
  },
  "message": "request ok",
  "service.name": "apm-server",
  "url.original": "/",
  "http.request.method": "GET",
  "user_agent.original": "curl/8.7.1",
  "source.address": "127.0.0.1",
  "http.request.id": "a9fa6cfd-61cf-441e-8494-f0a8ce5467a2",
  "event.duration": 399334,
  "http.request.body.bytes": 0,
  "http.response.status_code": 200,
  "ecs.version": "1.6.0"
}
```

```shell
> curl -X HEAD -v http://localhost:8200/
```
```json
{
  "log.level": "info",
  "@timestamp": "2025-06-18T12:04:03.125-0400",
  "log.logger": "request",
  "log.origin": {
    "function": "github.com/elastic/apm-server/internal/beater/api.apmMiddleware.LogMiddleware.func1.1",
    "file.name": "middleware/log_middleware.go",
    "file.line": 62
  },
  "message": "request ok",
  "service.name": "apm-server",
  "url.original": "/",
  "http.request.method": "HEAD",
  "user_agent.original": "curl/8.7.1",
  "source.address": "127.0.0.1",
  "http.request.id": "ba41b345-8428-445b-aaee-fcc902db53d5",
  "event.duration": 111166,
  "http.request.body.bytes": 0,
  "http.response.status_code": 200,
  "ecs.version": "1.6.0"
}
```

```shell
> curl -X POST -v http://localhost:8200/
```
```json
{
  "log.level": "info",
  "@timestamp": "2025-06-18T12:04:55.830-0400",
  "log.logger": "request",
  "log.origin": {
    "function": "github.com/elastic/apm-server/internal/beater/api.apmMiddleware.LogMiddleware.func1.1",
    "file.name": "middleware/log_middleware.go",
    "file.line": 62
  },
  "message": "request ok",
  "service.name": "apm-server",
  "url.original": "/",
  "http.request.method": "POST",
  "user_agent.original": "curl/8.7.1",
  "source.address": "127.0.0.1",
  "http.request.id": "b48adab3-58aa-4204-9950-dadbb7ba3c12",
  "event.duration": 84334,
  "http.request.body.bytes": 0,
  "http.response.status_code": 200,
  "ecs.version": "1.6.0"
}
```

- Commit AFTER change: `git checkout 2aaa73a56`

```shell
> curl -X HEAD -v http://localhost:8200/
```
```json
{
  "log.level": "error",
  "@timestamp": "2025-06-18T12:07:01.079-0400",
  "log.logger": "request",
  "log.origin": {
    "function": "github.com/elastic/apm-server/internal/beater/api.apmMiddleware.LogMiddleware.func1.1",
    "file.name": "middleware/log_middleware.go",
    "file.line": 59
  },
  "message": "method not supported",
  "service.name": "apm-server",
  "url.original": "/",
  "http.request.method": "POST",
  "user_agent.original": "curl/8.7.1",
  "source.address": "127.0.0.1",
  "http.request.id": "f023bc87-5e22-4c5b-bb08-80155d8177ee",
  "event.duration": 570875,
  "http.request.body.bytes": 0,
  "http.response.status_code": 405,
  "error.message": "method not supported: this is the server information endpoint; did you mean to send data to another endpoint instead?",
  "ecs.version": "1.6.0"
}
```
author_association: CONTRIBUTOR