Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api/components/responses.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions docs/api/components/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/api/paths/public/keyspace-_bulk_docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
bbrks marked this conversation as resolved.
Expand Down
8 changes: 8 additions & 0 deletions docs/api/paths/public/keyspace-_local-docid.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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':
Expand All @@ -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':
Expand All @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions rest/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +4479 to +4480
Comment thread
bbrks marked this conversation as resolved.
},
})
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)
Comment on lines +4491 to +4492

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)
})
}
}
25 changes: 16 additions & 9 deletions rest/bulk_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions rest/config_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
Comment thread
bbrks marked this conversation as resolved.
"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")},
Expand Down
8 changes: 8 additions & 0 deletions rest/config_startup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions rest/doc_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions rest/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Loading