From 5e02015675f3d00dc2dd068e994895cada352080 Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Wed, 30 Sep 2026 17:59:58 +0530 Subject: [PATCH 1/3] disable local endpoint for public API --- rest/api_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++ rest/bulk_api.go | 5 ++++ rest/config_startup.go | 8 +++++++ rest/doc_api.go | 13 +++++++++++ rest/handler.go | 4 ++++ 5 files changed, 83 insertions(+) diff --git a/rest/api_test.go b/rest/api_test.go index 053d2043d6..d752ff44da 100644 --- a/rest/api_test.go +++ b/rest/api_test.go @@ -4446,3 +4446,56 @@ func TestInvalidRevTreeRestGetRepairsAndRenames(t *testing.T) { RequireStatus(t, resp, http.StatusOK) assert.Equal(t, int64(1), invalidRevTreeCount.Value(), "a repaired document should not be reported again") } + +// TestLocalEndpointForPublicAPI makes sure that api.enable_local_endpoint_for_public_api controls GET, PUT and DELETE +// on _local docs for an authenticated public API user, and that a disabled endpoint returns 403. +func TestLocalEndpointForPublicAPI(t *testing.T) { + tests := []struct { + enabled bool + expectedStatus int + expectedPutStatus int + expectedBulkStatus int + expectedStoredStatus int + expectedLocalStoredStatus int + }{ + { + enabled: true, + expectedStatus: http.StatusNotFound, + expectedPutStatus: http.StatusCreated, + expectedBulkStatus: http.StatusCreated, + expectedStoredStatus: http.StatusOK, + expectedLocalStoredStatus: http.StatusOK, + }, + { + enabled: false, + expectedStatus: http.StatusForbidden, + expectedPutStatus: http.StatusForbidden, + expectedBulkStatus: http.StatusForbidden, + expectedStoredStatus: http.StatusNotFound, + expectedLocalStoredStatus: http.StatusForbidden, + }, + } + for _, test := range tests { + rt := NewRestTester(t, &RestTesterConfig{ + SyncFn: `function(doc) {channel(doc.channels);}`, + MutateStartupConfig: func(config *StartupConfig) { + config.API.EnableLocalEndpointForPublicAPI = new(test.enabled) + }, + }) + defer rt.Close() + + rt.CreateUser("alice", []string{"chanA"}) + + RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) + RequireStatus(t, rt.SendUserRequest(http.MethodPut, "/{{.keyspace}}/_local/doc2", `{"key":"value"}`, "alice"), test.expectedPutStatus) + RequireStatus(t, rt.SendUserRequest(http.MethodDelete, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) + bulkBody := `{"docs": [{"_id": "doc3", "channels": ["chanA"]}, {"_id": "_local/bulk1", "key": "value"}]}` + RequireStatus(t, rt.SendUserRequest(http.MethodPost, "/{{.keyspace}}/_bulk_docs", bulkBody, "alice"), test.expectedBulkStatus) + + // A rejected _bulk_docs request writes none of its docs, including the normal doc. + RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/doc3", "", "alice"), test.expectedStoredStatus) + RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_local/bulk1", "", "alice"), test.expectedLocalStoredStatus) + + RequireStatus(t, rt.SendUserRequest(http.MethodPost, "/{{.keyspace}}/_bulk_docs", `{"docs": [{"_id": "doc4", "channels": ["chanA"]}]}`, "alice"), http.StatusCreated) + } +} diff --git a/rest/bulk_api.go b/rest/bulk_api.go index 8c57a65e2f..f65d129ba3 100644 --- a/rest/bulk_api.go +++ b/rest/bulk_api.go @@ -573,6 +573,11 @@ func (h *handler) handleBulkDocs() error { } } + if len(localDocs) > 0 && h.localDocsDisabledForPublicAPI() { + h.writeJSONStatus(http.StatusForbidden, "") + return nil + } + result := make([]db.Body, 0, len(docs)) for _, item := range docs { doc := item.(map[string]any) diff --git a/rest/config_startup.go b/rest/config_startup.go index 94d3f3dd62..28ed670889 100644 --- a/rest/config_startup.go +++ b/rest/config_startup.go @@ -124,6 +124,7 @@ type APIConfig struct { MetricsInterfaceAuthentication *bool `json:"metrics_interface_authentication,omitempty" help:"Whether the metrics API requires authentication"` EnableAdminAuthenticationPermissionsCheck *bool `json:"enable_advanced_auth_dp,omitempty" help:"Whether to enable the DP permissions check feature of admin auth"` + EnableLocalEndpointForPublicAPI *bool `json:"enable_local_endpoint_for_public_api" help:"Whether to enable the local endpoint for public API"` ServerReadTimeout *base.ConfigDuration `json:"server_read_timeout,omitempty" help:"Maximum duration before timing out read of the HTTP(S) request"` ServerWriteTimeout *base.ConfigDuration `json:"server_write_timeout,omitempty" help:"Maximum duration before timing out write of the HTTP(S) response"` @@ -309,3 +310,10 @@ func logRuntimeEnvironment(ctx context.Context, rs *RuntimeStatus) { func (sc *StartupConfig) Merge(new *StartupConfig) error { return base.ConfigMerge(sc, new) } + +func (sc *StartupConfig) LocalEndpointForPublicAPIEnabled() bool { + if sc.API.EnableLocalEndpointForPublicAPI == nil { + return true + } + return *sc.API.EnableLocalEndpointForPublicAPI +} diff --git a/rest/doc_api.go b/rest/doc_api.go index 219e0c5106..3b3bc69a43 100644 --- a/rest/doc_api.go +++ b/rest/doc_api.go @@ -842,6 +842,10 @@ func (h *handler) handleDeleteDoc() error { // HTTP handler for a GET of a _local document func (h *handler) handleGetLocalDoc() error { + if h.localDocsDisabledForPublicAPI() { + h.writeJSONStatus(http.StatusForbidden, "") + return nil + } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -867,6 +871,11 @@ func (h *handler) handleGetLocalDoc() error { // HTTP handler for a PUT of a _local document func (h *handler) handlePutLocalDoc() error { + + if h.localDocsDisabledForPublicAPI() { + h.writeJSONStatus(http.StatusForbidden, "") + return nil + } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -899,6 +908,10 @@ func auditEventForDocumentUpsert(ctx context.Context, docid string, revid string // HTTP handler for a DELETE of a _local document func (h *handler) handleDelLocalDoc() error { + if h.localDocsDisabledForPublicAPI() { + h.writeJSONStatus(http.StatusForbidden, "") + return nil + } docid := h.PathVar("docid") rev := h.getQuery("rev") localDocID := db.LocalDocPrefix + docid diff --git a/rest/handler.go b/rest/handler.go index 5abe070372..a9e29dc6b6 100644 --- a/rest/handler.go +++ b/rest/handler.go @@ -1868,3 +1868,7 @@ func (h *handler) getCORSConfig() *auth.CORSConfig { } return h.server.Config.API.CORS } + +func (h *handler) localDocsDisabledForPublicAPI() bool { + return h.serverType == publicServer && !h.server.Config.LocalEndpointForPublicAPIEnabled() +} From 14cc55c7953531c0ebd001419be5d518566625fa Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Wed, 30 Sep 2026 18:10:23 +0530 Subject: [PATCH 2/3] add config to CLI --- rest/config_flags.go | 1 + 1 file changed, 1 insertion(+) diff --git a/rest/config_flags.go b/rest/config_flags.go index 858728faa3..dbc74d3805 100644 --- a/rest/config_flags.go +++ b/rest/config_flags.go @@ -51,6 +51,7 @@ func registerConfigFlags(config *StartupConfig, fs *flag.FlagSet) map[string]con "api.admin_interface_authentication": {config: &config.API.AdminInterfaceAuthentication, flagValue: fs.Bool("api.admin_interface_authentication", false, "Whether the admin API requires authentication")}, "api.metrics_interface_authentication": {config: &config.API.MetricsInterfaceAuthentication, flagValue: fs.Bool("api.metrics_interface_authentication", false, "Whether the metrics API requires authentication")}, "api.enable_admin_authentication_permissions_check": {config: &config.API.EnableAdminAuthenticationPermissionsCheck, flagValue: fs.Bool("api.enable_admin_authentication_permissions_check", false, "Whether to enable the DP permissions check feature of admin auth")}, + "api.enable_local_endpoint_for_public_api": {config: &config.API.EnableLocalEndpointForPublicAPI, flagValue: fs.Bool("api.enable_local_endpoint_for_public_api", true, "Whether to enable the local endpoint for public API")}, "api.server_read_timeout": {config: &config.API.ServerReadTimeout, flagValue: fs.String("api.server_read_timeout", "", "Maximum duration.Second before timing out read of the HTTP(S) request")}, "api.server_write_timeout": {config: &config.API.ServerWriteTimeout, flagValue: fs.String("api.server_write_timeout", "", "Maximum duration.Second before timing out write of the HTTP(S) response")}, "api.read_header_timeout": {config: &config.API.ReadHeaderTimeout, flagValue: fs.String("api.read_header_timeout", "", "The amount of time allowed to read request headers")}, From c711fcff83acee94fe2fafa0826a28b99c2bc72d Mon Sep 17 00:00:00 2001 From: Ritesh Kumar Date: Thu, 1 Oct 2026 13:32:49 +0530 Subject: [PATCH 3/3] fixes based on PR comments and changed the bulk_api behaviour - bulk api now returns row level errors - fixed the way the endpoints return an error to prevent 406 - added case to test the HEAD request as well - open api spec have been updated - added test case to ensure the admin api is not affected by this change - added omitempty tag to the config --- docs/api/components/responses.yaml | 9 ++ docs/api/components/schemas.yaml | 7 ++ .../api/paths/public/keyspace-_bulk_docs.yaml | 2 + .../paths/public/keyspace-_local-docid.yaml | 8 ++ rest/api_test.go | 83 ++++++++++--------- rest/bulk_api.go | 30 +++---- rest/config_startup.go | 2 +- rest/doc_api.go | 10 +-- 8 files changed, 92 insertions(+), 59 deletions(-) diff --git a/docs/api/components/responses.yaml b/docs/api/components/responses.yaml index 0d8f5983ba..46f5da10f3 100644 --- a/docs/api/components/responses.yaml +++ b/docs/api/components/responses.yaml @@ -172,6 +172,15 @@ changes-feed: $ref: ./schemas.yaml#/Changes-feed ddoc-forbidden: description: Forbidden access possibly due to not using the Admin API or the design document is a built-in Sync Gateway one. +local-docs-forbidden: + description: The `_local` endpoints are disabled on the public API, because `api.enable_local_endpoint_for_public_api` is `false`. + content: + application/json: + schema: + $ref: ./schemas.yaml#/HTTP-Error + example: + error: Forbidden + reason: _local endpoint is disabled Replicator-created: description: Created new replication successfully Replicator-updated: diff --git a/docs/api/components/schemas.yaml b/docs/api/components/schemas.yaml index b5f00da3df..b3f99d405d 100644 --- a/docs/api/components/schemas.yaml +++ b/docs/api/components/schemas.yaml @@ -2261,6 +2261,13 @@ Startup-config: Defaults to `true` if using Enterprise Edition or `false` if using Community Edition. type: boolean + enable_local_endpoint_for_public_api: + description: |- + Whether the public API allows requests for `_local` documents. + + If `false`, the public `/{keyspace}/_local/{docid}` endpoints return `403`, and `_bulk_docs` does not write `_local` documents. The admin API is not affected. + type: boolean + default: true hide_product_version: description: Whether product versions removed from Server headers and REST API responses type: boolean diff --git a/docs/api/paths/public/keyspace-_bulk_docs.yaml b/docs/api/paths/public/keyspace-_bulk_docs.yaml index ac89ffd8a2..39d56a5a5e 100644 --- a/docs/api/paths/public/keyspace-_bulk_docs.yaml +++ b/docs/api/paths/public/keyspace-_bulk_docs.yaml @@ -50,6 +50,8 @@ post: Executed all operations. Each object in the returned array represents a document. Each document should be checked to make sure it was successfully added to the database. + + If `api.enable_local_endpoint_for_public_api` is `false`, Sync Gateway does not write `_local` documents in the request. Each one gets a `403` status. Other documents in the request are still written. content: application/json: schema: diff --git a/docs/api/paths/public/keyspace-_local-docid.yaml b/docs/api/paths/public/keyspace-_local-docid.yaml index 7cb1431458..bc0ae6facb 100644 --- a/docs/api/paths/public/keyspace-_local-docid.yaml +++ b/docs/api/paths/public/keyspace-_local-docid.yaml @@ -24,6 +24,8 @@ get: description: Successfully found local document '400': $ref: ../../components/responses.yaml#/request-problem + '403': + $ref: ../../components/responses.yaml#/local-docs-forbidden '404': $ref: ../../components/responses.yaml#/Not-found tags: @@ -54,6 +56,8 @@ put: $ref: ../../components/schemas.yaml#/New-revision '400': $ref: ../../components/responses.yaml#/request-problem + '403': + $ref: ../../components/responses.yaml#/local-docs-forbidden '404': $ref: ../../components/responses.yaml#/Not-found '409': @@ -79,6 +83,8 @@ delete: description: Successfully removed the local document. '400': $ref: ../../components/responses.yaml#/request-problem + '403': + $ref: ../../components/responses.yaml#/local-docs-forbidden '404': $ref: ../../components/responses.yaml#/Not-found '409': @@ -92,6 +98,8 @@ head: description: Document exists '400': $ref: ../../components/responses.yaml#/request-problem + '403': + $ref: ../../components/responses.yaml#/local-docs-forbidden '404': $ref: ../../components/responses.yaml#/Not-found tags: diff --git a/rest/api_test.go b/rest/api_test.go index d752ff44da..b9f53b2c4f 100644 --- a/rest/api_test.go +++ b/rest/api_test.go @@ -4447,55 +4447,64 @@ func TestInvalidRevTreeRestGetRepairsAndRenames(t *testing.T) { assert.Equal(t, int64(1), invalidRevTreeCount.Value(), "a repaired document should not be reported again") } -// TestLocalEndpointForPublicAPI makes sure that api.enable_local_endpoint_for_public_api controls GET, PUT and DELETE -// on _local docs for an authenticated public API user, and that a disabled endpoint returns 403. +// TestLocalEndpointForPublicAPI makes sure that api.enable_local_endpoint_for_public_api controls GET, PUT, DELETE and +// _bulk_docs on _local docs for an authenticated public API user, and that a disabled endpoint returns 403. func TestLocalEndpointForPublicAPI(t *testing.T) { tests := []struct { - enabled bool - expectedStatus int - expectedPutStatus int - expectedBulkStatus int - expectedStoredStatus int - expectedLocalStoredStatus int + enabled bool + expectedStatus int + expectedPutStatus int + expectedLocalStored int + expectedBulkLocalStatus any }{ { - enabled: true, - expectedStatus: http.StatusNotFound, - expectedPutStatus: http.StatusCreated, - expectedBulkStatus: http.StatusCreated, - expectedStoredStatus: http.StatusOK, - expectedLocalStoredStatus: http.StatusOK, + enabled: true, + expectedStatus: http.StatusNotFound, + expectedPutStatus: http.StatusCreated, + expectedLocalStored: http.StatusOK, + expectedBulkLocalStatus: nil, }, { - enabled: false, - expectedStatus: http.StatusForbidden, - expectedPutStatus: http.StatusForbidden, - expectedBulkStatus: http.StatusForbidden, - expectedStoredStatus: http.StatusNotFound, - expectedLocalStoredStatus: http.StatusForbidden, + enabled: false, + expectedStatus: http.StatusForbidden, + expectedPutStatus: http.StatusForbidden, + expectedLocalStored: http.StatusNotFound, + expectedBulkLocalStatus: float64(http.StatusForbidden), }, } for _, test := range tests { - rt := NewRestTester(t, &RestTesterConfig{ - SyncFn: `function(doc) {channel(doc.channels);}`, - MutateStartupConfig: func(config *StartupConfig) { - config.API.EnableLocalEndpointForPublicAPI = new(test.enabled) - }, - }) - defer rt.Close() + t.Run(fmt.Sprintf("enabled=%t", test.enabled), func(t *testing.T) { + rt := NewRestTester(t, &RestTesterConfig{ + SyncFn: `function(doc) {channel(doc.channels);}`, + MutateStartupConfig: func(config *StartupConfig) { + config.API.EnableLocalEndpointForPublicAPI = new(test.enabled) + }, + }) + defer rt.Close() + + rt.CreateUser("alice", []string{"chanA"}) - rt.CreateUser("alice", []string{"chanA"}) + RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) + RequireStatus(t, rt.SendUserRequest(http.MethodHead, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) + RequireStatus(t, rt.SendUserRequestWithHeaders(http.MethodGet, "/{{.keyspace}}/_local/doc1", "", map[string]string{"Accept": "text/html"}, "alice", RestTesterDefaultUserPassword), test.expectedStatus) + RequireStatus(t, rt.SendAdminRequest(http.MethodGet, "/{{.keyspace}}/_local/doc1", ""), http.StatusNotFound) + RequireStatus(t, rt.SendUserRequest(http.MethodPut, "/{{.keyspace}}/_local/doc2", `{"key":"value"}`, "alice"), test.expectedPutStatus) + RequireStatus(t, rt.SendUserRequest(http.MethodDelete, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) - RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) - RequireStatus(t, rt.SendUserRequest(http.MethodPut, "/{{.keyspace}}/_local/doc2", `{"key":"value"}`, "alice"), test.expectedPutStatus) - RequireStatus(t, rt.SendUserRequest(http.MethodDelete, "/{{.keyspace}}/_local/doc1", "", "alice"), test.expectedStatus) - bulkBody := `{"docs": [{"_id": "doc3", "channels": ["chanA"]}, {"_id": "_local/bulk1", "key": "value"}]}` - RequireStatus(t, rt.SendUserRequest(http.MethodPost, "/{{.keyspace}}/_bulk_docs", bulkBody, "alice"), test.expectedBulkStatus) + bulkBody := `{"docs": [{"_id": "doc3", "channels": ["chanA"]}, {"_id": "_local/bulk1", "key": "value"}]}` + resp := rt.SendUserRequest(http.MethodPost, "/{{.keyspace}}/_bulk_docs", bulkBody, "alice") + RequireStatus(t, resp, http.StatusCreated) - // A rejected _bulk_docs request writes none of its docs, including the normal doc. - RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/doc3", "", "alice"), test.expectedStoredStatus) - RequireStatus(t, rt.SendUserRequest(http.MethodGet, "/{{.keyspace}}/_local/bulk1", "", "alice"), test.expectedLocalStoredStatus) + var results []map[string]any + require.NoError(t, base.JSONUnmarshal(resp.Body.Bytes(), &results)) + require.Len(t, results, 2) + assert.Equal(t, "doc3", results[0]["id"]) + assert.Nil(t, results[0]["status"]) + assert.Equal(t, "_local/bulk1", results[1]["id"]) + assert.Equal(t, test.expectedBulkLocalStatus, results[1]["status"]) - RequireStatus(t, rt.SendUserRequest(http.MethodPost, "/{{.keyspace}}/_bulk_docs", `{"docs": [{"_id": "doc4", "channels": ["chanA"]}]}`, "alice"), http.StatusCreated) + RequireStatus(t, rt.SendAdminRequest(http.MethodGet, "/{{.keyspace}}/doc3", ""), http.StatusOK) + RequireStatus(t, rt.SendAdminRequest(http.MethodGet, "/{{.keyspace}}/_local/bulk1", ""), test.expectedLocalStored) + }) } } diff --git a/rest/bulk_api.go b/rest/bulk_api.go index f65d129ba3..dd7b0414e3 100644 --- a/rest/bulk_api.go +++ b/rest/bulk_api.go @@ -573,11 +573,6 @@ func (h *handler) handleBulkDocs() error { } } - if len(localDocs) > 0 && h.localDocsDisabledForPublicAPI() { - h.writeJSONStatus(http.StatusForbidden, "") - return nil - } - result := make([]db.Body, 0, len(docs)) for _, item := range docs { doc := item.(map[string]any) @@ -628,18 +623,25 @@ func (h *handler) handleBulkDocs() error { offset := len(db.LocalDocPrefix) docid, _ := doc[db.BodyId].(string) idslug := docid[offset:] - revid, isNewDoc, docErr := h.collection.PutSpecial(h.ctx(), db.DocTypeLocal, idslug, doc) status := db.Body{} status["id"] = docid - if docErr != nil { - code, msg := base.ErrorAsHTTPStatus(docErr) - status["status"] = code - status["error"] = base.CouchHTTPErrorName(code) - status["reason"] = msg - base.InfofCtx(h.ctx(), base.KeyAll, "\tBulkDocs: Local Doc %q --> %d %s (%v)", base.UD(docid), code, msg, docErr) + if h.localDocsDisabledForPublicAPI() { + status["status"] = http.StatusForbidden + status["error"] = base.CouchHTTPErrorName(http.StatusForbidden) + status["reason"] = "_local endpoint is disabled" + base.InfofCtx(h.ctx(), base.KeyAll, "\tBulkDocs: _local endpoint is disabled, did not write Doc %q", base.UD(docid)) } else { - status["rev"] = revid - auditEventForDocumentUpsert(h.ctx(), docid, revid, isNewDoc) + revid, isNewDoc, docErr := h.collection.PutSpecial(h.ctx(), db.DocTypeLocal, idslug, doc) + if docErr != nil { + code, msg := base.ErrorAsHTTPStatus(docErr) + status["status"] = code + status["error"] = base.CouchHTTPErrorName(code) + status["reason"] = msg + base.InfofCtx(h.ctx(), base.KeyAll, "\tBulkDocs: Local Doc %q --> %d %s (%v)", base.UD(docid), code, msg, docErr) + } else { + status["rev"] = revid + auditEventForDocumentUpsert(h.ctx(), docid, revid, isNewDoc) + } } result = append(result, status) } diff --git a/rest/config_startup.go b/rest/config_startup.go index 28ed670889..8d7e90acd6 100644 --- a/rest/config_startup.go +++ b/rest/config_startup.go @@ -124,7 +124,7 @@ type APIConfig struct { MetricsInterfaceAuthentication *bool `json:"metrics_interface_authentication,omitempty" help:"Whether the metrics API requires authentication"` EnableAdminAuthenticationPermissionsCheck *bool `json:"enable_advanced_auth_dp,omitempty" help:"Whether to enable the DP permissions check feature of admin auth"` - EnableLocalEndpointForPublicAPI *bool `json:"enable_local_endpoint_for_public_api" help:"Whether to enable the local endpoint for public API"` + EnableLocalEndpointForPublicAPI *bool `json:"enable_local_endpoint_for_public_api,omitempty" help:"Whether to enable the local endpoint for public API"` ServerReadTimeout *base.ConfigDuration `json:"server_read_timeout,omitempty" help:"Maximum duration before timing out read of the HTTP(S) request"` ServerWriteTimeout *base.ConfigDuration `json:"server_write_timeout,omitempty" help:"Maximum duration before timing out write of the HTTP(S) response"` diff --git a/rest/doc_api.go b/rest/doc_api.go index 3b3bc69a43..427f7fc2df 100644 --- a/rest/doc_api.go +++ b/rest/doc_api.go @@ -843,8 +843,7 @@ func (h *handler) handleDeleteDoc() error { // HTTP handler for a GET of a _local document func (h *handler) handleGetLocalDoc() error { if h.localDocsDisabledForPublicAPI() { - h.writeJSONStatus(http.StatusForbidden, "") - return nil + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -871,10 +870,8 @@ func (h *handler) handleGetLocalDoc() error { // HTTP handler for a PUT of a _local document func (h *handler) handlePutLocalDoc() error { - if h.localDocsDisabledForPublicAPI() { - h.writeJSONStatus(http.StatusForbidden, "") - return nil + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -909,8 +906,7 @@ func auditEventForDocumentUpsert(ctx context.Context, docid string, revid string // HTTP handler for a DELETE of a _local document func (h *handler) handleDelLocalDoc() error { if h.localDocsDisabledForPublicAPI() { - h.writeJSONStatus(http.StatusForbidden, "") - return nil + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") } docid := h.PathVar("docid") rev := h.getQuery("rev")