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 053d2043d6..b9f53b2c4f 100644 --- a/rest/api_test.go +++ b/rest/api_test.go @@ -4446,3 +4446,65 @@ 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, 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 + expectedLocalStored int + expectedBulkLocalStatus any + }{ + { + enabled: true, + expectedStatus: http.StatusNotFound, + expectedPutStatus: http.StatusCreated, + expectedLocalStored: http.StatusOK, + expectedBulkLocalStatus: nil, + }, + { + enabled: false, + expectedStatus: http.StatusForbidden, + expectedPutStatus: http.StatusForbidden, + expectedLocalStored: http.StatusNotFound, + expectedBulkLocalStatus: float64(http.StatusForbidden), + }, + } + for _, test := range tests { + 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"}) + + 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) + + 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) + + 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.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 8c57a65e2f..dd7b0414e3 100644 --- a/rest/bulk_api.go +++ b/rest/bulk_api.go @@ -623,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_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")}, diff --git a/rest/config_startup.go b/rest/config_startup.go index 94d3f3dd62..8d7e90acd6 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,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"` @@ -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..427f7fc2df 100644 --- a/rest/doc_api.go +++ b/rest/doc_api.go @@ -842,6 +842,9 @@ func (h *handler) handleDeleteDoc() error { // HTTP handler for a GET of a _local document func (h *handler) handleGetLocalDoc() error { + if h.localDocsDisabledForPublicAPI() { + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") + } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -867,6 +870,9 @@ func (h *handler) handleGetLocalDoc() error { // HTTP handler for a PUT of a _local document func (h *handler) handlePutLocalDoc() error { + if h.localDocsDisabledForPublicAPI() { + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") + } docid := h.PathVar("docid") localDocID := db.LocalDocPrefix + docid @@ -899,6 +905,9 @@ 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() { + return base.HTTPErrorf(http.StatusForbidden, "_local endpoint is disabled") + } 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() +}