Minimal security, with Caddy in front
Verified against Elasticsearch 9.5.5 and OpenSearch 3.9.0 on 29 October 2026. Configs: github.com/jtsunne/small-elasticsearch-lab at v0.1.0.
For one node, keep authentication on, skip certificates on the node, terminate TLS at Caddy, give applications a scoped, expiring API key, and treat Caddy’s path allowlist as a speed bump, not as access control. I ran every bypass I could think of against this setup. Caddy stopped the path tricks, but a _bulk body can name any index, and an expensive query passes every proxy rule; only the Elasticsearch role stops the first, and nothing on this page stops the second. If Kibana is exposed, its console reaches every API the logged-in user’s role allows, whatever the proxy says.
| Layer | One node | Three nodes | Enforced by | Tested in |
|---|---|---|---|---|
| Authentication | required | required | Elasticsearch | es-single |
| Transport TLS | not needed with discovery.type: single-node | required | Elasticsearch bootstrap check | es-single, es-three-local |
| HTTP TLS to clients | at Caddy | at Caddy | Caddy | es-single |
| IP allowlist | yes | yes | Caddy, remote_ip | es-single, rows L-P1 |
| Method and path allowlist | yes, a speed bump | yes, a speed bump | Caddy, raw-URI expression matchers | es-single, rows L-P4 to L-P20 |
| Body size limit | 20 MB | 20 MB | Caddy, request_body | es-single, row L-P14 |
| What an app may read and write | role plus API key | role plus API key | Elasticsearch | es-single, rows L-P3, L-P4, L-P12 |
What I ran: the scenarios es-single (83 of 83 rows), es-single-kibana (90 of 91, see Kibana), os-single (62 of 62) and os-single-dashboards (70 of 70), on 29 October 2026, on my laptop (Linux, cgroup v2, 12 logical CPUs, about 15.5 GiB of RAM, Docker 29.9.1, Compose 5.6.0), with the images pinned in the companion repo’s versions.env. One laptop run is one data point. A row id such as es-single-56 is a line in scenarios/es-single/expected.tsv; its result is in results/tests/2026-10-29-es-single.tap in the companion repo, and results/FINDINGS-2026-10-29.md explains every finding.
What Elasticsearch requires
- Authentication on, always. With
xpack.security.enabled: trueevery request needs credentials; an allowlisted client without them gets 401 (es-single-09,-10). - Transport TLS only when there is a second node. “Transport SSL must be enabled if security is enabled” is a bootstrap check, and bootstrap checks are not enforced with
discovery.type: single-node. [official] The next page adds it when the cluster grows. - HTTP TLS is optional on a self-managed node. [official] Elastic calls this setup “not sufficient for production mode clusters” and HTTP TLS “highly recommended”. [official] Here Caddy provides HTTPS for everything that leaves the host; the hop from Caddy to the node is plain HTTP on a host-local Docker bridge. That is the trade: no certificates to manage on the node, and anything with access to that bridge can read the traffic.
- Setting any
xpack.security.*turns off security auto-configuration. That is intended here: no enrollment tokens, no generated certificates, nothing I did not write down.
The compose file
The node publishes no port. Only Caddy does, on loopback by default. From the host, 127.0.0.1:9200 is refused (es-single-06).
name: es-single
services:
es01:
image: ${ES_IMAGE:?ES_IMAGE comes from versions.env}
environment:
- node.name=es01
- cluster.name=es-single
- discovery.type=single-node
- xpack.security.enabled=true
# Allowed without node certificates only because discovery.type is
# single-node; a second node needs transport TLS.
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false
- xpack.license.self_generated.type=basic
- bootstrap.memory_lock=true
- ELASTIC_PASSWORD=${ELASTIC_PASSWORD:?set ELASTIC_PASSWORD in .env}
# Same value auto-sizing picks for a 2 GiB container, set explicitly so the
# OpenSearch scenarios (which do not auto-size) run the same heap. Set
# ES_HEAP_OPTS to an empty string to let Elasticsearch size it (L-M1).
- ES_JAVA_OPTS=${ES_HEAP_OPTS--Xms1g -Xmx1g}
cpus: 1
mem_limit: 2g
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65535
hard: 65535
volumes:
- esdata:/usr/share/elasticsearch/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200 | grep -q 'missing authentication credentials'"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
caddy:
image: ${CADDY_IMAGE:?CADDY_IMAGE comes from versions.env}
depends_on:
es01:
condition: service_healthy
environment:
- ES_SITE_ADDRESS=${ES_SITE_ADDRESS:?set ES_SITE_ADDRESS in .env}
- ALLOWED_CIDRS=${ALLOWED_CIDRS:?set ALLOWED_CIDRS in .env}
ports:
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-80}:80"
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- backend
networks:
backend: {}
volumes:
esdata: {}
caddy_data: {}
caddy_config: {}
Tested at v0.1.0:
scenarios/es-single/compose.yaml.
Three choices in it are worth a sentence each:
ES_JAVA_OPTS=-Xms1g -Xmx1gis what auto-sizing picks for a 2 GiB container anyway. I set it so the OpenSearch scenario, which does not size its heap from the container, runs the same heap. Without it, Elasticsearch 9.5.5 gave the node exactly 1 GiB by itself (es-single-02, auto-sizing run). Elastic does not recommendES_JAVA_OPTSin production; there, keep the default sizing.- No
node.processors: withcpus: 1the node reported 1 allocated processor, a write pool of 1 and a search pool of 2 (es-single-03,-04), so the quota is honoured on cgroup v2. - The healthcheck greps for
missing authentication credentials, as Elastic’s own compose file does. It proves the node answers HTTP, not that it is ready; see Readiness.
vm.max_map_count is a host setting. Elasticsearch asks for 1048576 [official]; with discovery.type: single-node the check is not enforced, but set it anyway, because the next page needs it.
The Caddyfile
# Requires Caddy 2.11.1 or later (CVE-2026-27587). The {$VAR:default} values let
# the same file run in compose, in CI validation and in the proxy-only test
# harness; compose always sets ES_SITE_ADDRESS and ALLOWED_CIDRS.
{
admin {$CADDY_ADMIN:localhost:2019}
servers {
protocols h1 h2
}
}
{$ES_SITE_ADDRESS:es.localhost} {
log
# route keeps the written order. Without it Caddy sorts directives and runs
# handle and reverse_proxy before abort, so the IP allowlist would not
# protect the proxied paths.
route {
# remote_ip is the immediate TCP peer. Behind another proxy or Docker's
# userland proxy that is not the client, and this check means nothing.
@outside not remote_ip {$ALLOWED_CIDRS:127.0.0.1/32}
abort @outside
request_body {
max_size 20MB
}
# Matched against the raw request URI, because reverse_proxy forwards the
# raw path: path and path_regexp see a decoded, dot-cleaned path, so
# /secret/../app-a/_search would match them and still reach the node as
# written. Any encoding, dot segment, double slash, upper case, comma,
# wildcard, date math or remote-cluster prefix fails the pattern and
# gets the 404 below.
# This allowlist is a speed bump: a _bulk body can still name any index,
# and only the Elasticsearch role decides what the caller may touch.
@app_read {
method GET POST
expression `{http.request.uri}.matches("^/app-[a-z0-9][a-z0-9-]*/_(search|count)([?].*)?$")`
}
@app_write {
method POST PUT
expression `{http.request.uri}.matches("^/app-[a-z0-9][a-z0-9-]*/_bulk([?].*)?$")`
}
reverse_proxy @app_read {$ES_UPSTREAM:es01:9200} {
# Elasticsearch does not honour this header; dropping it only keeps
# the upstream request equal to what the method matcher saw.
header_up -X-HTTP-Method-Override
}
reverse_proxy @app_write {$ES_UPSTREAM:es01:9200} {
header_up -X-HTTP-Method-Override
}
respond 404
}
}
Tested at v0.1.0:
scenarios/es-single/Caddyfile.
Caddy 2.11.1 or later is required because of CVE-2026-27587, a path matcher bypass with %xx in patterns; the lab pins 2.11.7. [official]
Two things in this file are there because the obvious version is broken.
Order. Caddy does not run directives in the order you write them. It sorts them, and handle and reverse_proxy come before abort. A top-level @denied not remote_ip ... with abort @denied, followed by handle blocks that proxy, lets every client on the internet reach the proxied paths. [official] The companion repo’s proxy-only harness (Caddy 2.11.7 built locally, an echo server as upstream) showed it: with that structure a non-allowlisted client reached /app-a/_search and /app-a/_bulk. Everything inside one route block keeps the written order, so the allowlist runs first.
Raw path versus cleaned path. The path and path_regexp matchers see a decoded, dot-cleaned path. reverse_proxy forwards the request-target exactly as the client sent it. So a matcher can approve one path while the node receives another:
| Request-target sent | What path_regexp sees | What the node receives | With path_regexp ^/app-[a-z0-9-]+/_search$ | With this Caddyfile |
|---|---|---|---|---|
/secret/../app-a/_search | /app-a/_search | /secret/../app-a/_search | forwarded | 404 (es-single-41) |
//app-a/_search | /app-a/_search | //app-a/_search | forwarded | 404 (es-single-43) |
/app-a/%5Fsearch | /app-a/_search | /app-a/%5Fsearch | forwarded | 404 (es-single-31) |
The “forwarded” column comes from the proxy-only harness, not from a run against Elasticsearch: eight such rows reached the echo upstream unchanged with a path_regexp version of this file (tests/README.md, fixture Caddyfile.plan). Whether Elasticsearch would then route any of them somewhere harmful was not tested. The fix is to match what is forwarded: an expression matcher on {http.request.uri}, the raw path plus query string. Any encoding, dot segment, double slash, upper case, comma, wildcard or date math then fails the pattern. Clients that send canonical paths do not notice.
What the proxy stopped, and what it did not
Every row below ran through Caddy against Elasticsearch 9.5.5, with the app’s API key unless noted. “Node” is the same request sent straight to es01:9200 on the compose network, to show which layer would stop it if Caddy were not there.
| Request | Caddy | Node | Stopped by | Row |
|---|---|---|---|---|
Client outside the allowlist, valid key, GET /app-a/_search | connection reset, no HTTP status | Caddy | es-single-07 | |
Allowlisted, no credentials, GET /app-a/_search | forwarded | 401 | Elasticsearch | es-single-09 |
GET /app-a/_search with the key | forwarded | 200 | allowed | es-single-11 |
GET /_cluster/settings | 404 | 403 | Caddy, then the role | es-single-14, -24 |
GET /secret/_search | 404 | 403 | Caddy, then the role | es-single-21, -25 |
GET /_security/user | 404 | 403 | Caddy, then the role | es-single-17, -26 |
/%5Fcluster/settings, /%5fcluster/settings | 404 | Caddy | es-single-28, -29 | |
/_CLUSTER/settings, /APP-A/_search | 404 | Caddy | es-single-33, -34 | |
/app-a/../_cluster/settings, /secret/../app-a/_search | 404 | Caddy | es-single-38, -41 | |
/app-a,secret/_search, /app-a%2Csecret/_search | 404 | Caddy | es-single-47, -48 | |
/remote:app-a/_search | 404 | Caddy | es-single-49 | |
/app-*/_search, /_all/_search, /_search | 404 | Caddy | es-single-50, -53, -54 | |
Date math, /%3Capp-%7Bnow%2Fd%7D%3E/_search | 404 | Caddy | es-single-55 | |
_msearch, _async_search, _pit, /_search/scroll | 404 | Caddy | es-single-66 to -72 | |
DELETE /app-a, PUT /app-a/_doc/1, GET /app-a/_doc/seed | 404 | Caddy | es-single-73, -74, -76 | |
GET / without credentials | 404, no version banner | Caddy | es-single-77 | |
Absolute-form request line GET http://es01:9200/_cluster/settings | stream reset, nothing from the node | Caddy | es-single-65 | |
POST /app-a/_bulk with an item whose _index is secret | forwarded | 200, that item 403 | the role only | es-single-56 |
Bulk create into app-new, which matches app-* but does not exist | forwarded | 200, that item 403 | the role only | es-single-60 |
Bulk index over the existing id seed; bulk delete | forwarded | 200, that item 403 | the role only | es-single-58, -59 |
| Search with a scripted runtime field and a terms aggregation on it | forwarded | 200 | nothing | es-single-61 |
POST /app-a/_bulk with a 21 MB body | 413 | Caddy | es-single-62 | |
POST /app-a/_search with X-HTTP-Method-Override: DELETE | forwarded, header removed | 200, index intact | no effect | es-single-63, -64 |
Three rows are the reason this page exists:
- The bulk body names its own index. Caddy sees
/app-a/_bulkand forwards it. The body sayssecret. Only the role answers 403, per item, inside a 200 response. A client that only checks the HTTP status sees success. - Expensive queries pass. Aggregations, scripts and runtime mappings are body content. No proxy rule here looks at them, and I did not test any Elasticsearch-side limit on them.
- The body limit is not a wall. In the proxy-only harness, Caddy answered 413 for a body over 20 MB but had already streamed the first 20,000,000 bytes to the upstream, which then saw the request end early. Whether Elasticsearch discards such a truncated request was not tested.
What a blocked client sees
abort sends no HTTP response at all. What the client reports depends on the protocol. Over HTTP/2, which every HTTPS client here negotiated, Caddy resets the stream and curl exits with 92 (HTTP/2 stream ... was not closed cleanly). Over HTTP/1.1, Caddy closes the connection and curl exits with 52 (empty reply), or 55 or 56 when the reset arrives while sending or reading. My own test runner first counted only 52 to 56 as “aborted” and reported the HTTP/2 aborts as failures (es-single-07, -08, -65 in the first run that day). If you monitor for blocked clients, look for resets, not for a status code.
Users, roles and API keys
The application gets a role with read and create_doc on app-* and no cluster privileges, and a user app_x with that role. Its API key is created by elastic at setup, with an expiration and role_descriptors; scenarios/es-single/setup-roles.sh sends this request:
POST /_security/api_key
{
"name": "app-x-key",
"expiration": "30d",
"role_descriptors": {
"app_x_key": {
"indices": [
{"names": ["app-*"], "privileges": ["read", "create_doc"]}
]
}
}
}
Why it looks like this, from the create API key page: “By default, API keys never expire”, and with role_descriptors “the resultant permissions are an intersection of API keys permissions and the authenticated user’s permissions”. [official] The creator is elastic, so role_descriptors are the key’s only limit. Send the same request without them and you have made a superuser key.
What the lab showed for the key:
- It authenticates as
api_keynamedapp-x-key, owned byelastic(es-single-81). - The stored key has an expiration and exactly the
app_x_keydescriptor (es-single-82). - Neither
app_xnor the key can create another key: 403security_exception(es-single-79,-80). A leaked app password cannot mint keys.
The client sends Authorization: ApiKey <encoded>, where encoded is the field of the same name in the response. Rotation: create a new key, switch the application, then invalidate the old one by id as elastic.
create_doc does not allow the bulk index action
This one surprised me. create_doc is meant for “append only”: new documents, never overwrites. I expected it to cover any write without an _id. On Elasticsearch 9.5.5 it does not:
Request, with only read and create_doc on app-* | Result |
|---|---|
Bulk create items, no _id | 201 each (es-single-57) |
Bulk index item, no _id | 403 for that item (es-single-83) |
POST /app-a/_doc without an id, direct to the node | 201 (by hand, no row) |
PUT /app-a/_doc/<id>, direct to the node | 403 (by hand, no row) |
_create/<id> with an id that already exists, direct to the node | 409 (by hand, no row) |
The reason for the bulk index item:
action [indices:data/write/index:op_type/index] is unauthorized ... this action is granted by the index privileges [create,index,write,all]
Elasticsearch authorizes a bulk item by its operation type, not by whether the id is generated. If your client’s bulk helper sends index by default, configure it to send create. Granting index or write instead makes the error go away and also lets the key overwrite any document by id, which is what create_doc exists to prevent. create_doc does not create indices either (es-single-60).
Kibana
es-single-kibana adds Kibana 9.5.5 on a second Caddy site with the same IP allowlist and no path rules, because Kibana needs its whole URL space. Kibana connects to http://es01:9200 directly as kibana_system, not through Caddy. Kibana’s own login does the authentication.
# Requires Caddy 2.11.1 or later (CVE-2026-27587). The {$VAR:default} values let
# the same file run in compose, in CI validation and in the proxy-only test
# harness; compose always sets the site addresses and ALLOWED_CIDRS.
{
admin {$CADDY_ADMIN:localhost:2019}
servers {
protocols h1 h2
}
}
{$ES_SITE_ADDRESS:es.localhost} {
log
# route keeps the written order. Without it Caddy sorts directives and runs
# handle and reverse_proxy before abort, so the IP allowlist would not
# protect the proxied paths.
route {
# remote_ip is the immediate TCP peer. Behind another proxy or Docker's
# userland proxy that is not the client, and this check means nothing.
@outside not remote_ip {$ALLOWED_CIDRS:127.0.0.1/32}
abort @outside
request_body {
max_size 20MB
}
# Matched against the raw request URI, because reverse_proxy forwards the
# raw path: path and path_regexp see a decoded, dot-cleaned path, so
# /secret/../app-a/_search would match them and still reach the node as
# written. Any encoding, dot segment, double slash, upper case, comma,
# wildcard, date math or remote-cluster prefix fails the pattern and
# gets the 404 below.
# This allowlist is a speed bump: a _bulk body can still name any index,
# and only the Elasticsearch role decides what the caller may touch.
@app_read {
method GET POST
expression `{http.request.uri}.matches("^/app-[a-z0-9][a-z0-9-]*/_(search|count)([?].*)?$")`
}
@app_write {
method POST PUT
expression `{http.request.uri}.matches("^/app-[a-z0-9][a-z0-9-]*/_bulk([?].*)?$")`
}
reverse_proxy @app_read {$ES_UPSTREAM:es01:9200} {
# Elasticsearch does not honour this header; dropping it only keeps
# the upstream request equal to what the method matcher saw.
header_up -X-HTTP-Method-Override
}
reverse_proxy @app_write {$ES_UPSTREAM:es01:9200} {
header_up -X-HTTP-Method-Override
}
respond 404
}
}
# Kibana is a full Elasticsearch client for anyone who can log in (Dev Tools
# console), so the path allowlist above does not apply to Kibana users; their
# Elasticsearch roles do. Caddy only adds the IP allowlist and TLS here.
{$KIBANA_SITE_ADDRESS:kibana.localhost} {
log
route {
@outside not remote_ip {$ALLOWED_CIDRS:127.0.0.1/32}
abort @outside
reverse_proxy {$KIBANA_UPSTREAM:kibana:5601}
}
}
Tested at v0.1.0:
scenarios/es-single-kibana/Caddyfile.
Kibana’s console goes around the allowlist. Kibana sends Dev Tools console requests to Elasticsearch directly, with the logged-in user’s credentials. Through POST /api/console/proxy?path=_cluster/settings&method=GET, elastic got 200 with the cluster settings (es-single-kibana-83), although Caddy answers 404 for /_cluster/settings on the Elasticsearch site (es-single-kibana-14). app_x was refused with 401 or 403; the run did not record which (es-single-kibana-84). Whoever can reach the Kibana site and log in gets every Elasticsearch API their role allows. Give Kibana users roles as narrow as the API key’s, keep elastic for setup, and keep the Kibana site’s allowlist as tight as the Elasticsearch site’s.
The console proxy is an internal route in Kibana 9.5.5. Without the header x-elastic-internal-origin it answers:
400 uri [/api/console/proxy] with method [post] exists but is not available with the current configuration
With the header, any value, the request goes through (es-single-kibana-91 and -83). Kibana’s own browser client sends it on every request. It is not a security boundary.
Other Kibana notes from the run:
/api/statusanswers 200 without credentials once Kibana is ready, withavailablein the body;/loginanswers 200 for an allowlisted client; both are aborted for anyone else (es-single-kibana-79to-82).- Right after start, while Kibana created its system indices, the node was yellow for a moment: one primary unassigned (
es-single-kibana-01, the one failed row of the run). Wait for green with a timeout instead of checking once. - The container ran with 1024 MB, as in Elastic’s compose file; Elastic recommends at least 2048 MB with alerting. [official] I did not measure Kibana’s memory use or test alerting.
- Set
xpack.encryptedSavedObjects.encryptionKey, and keep it stable: without it Kibana generates one per start and some features, alerting among them, are unavailable.
Readiness is not “healthy”
docker compose up --wait returns when the healthcheck passes, and the healthcheck only proves that the node answers HTTP. On the three-node scenario the nodes were “healthy” while the .security-7 primary was still unassigned, and the setup script’s first call as elastic failed:
503 unavailable_shards_exception: at least one primary shard for the index [.security-7] is unavailable
authentication_processing_error: failed to retrieve password hash for reserved user [elastic]
My first run carried on without an app key and 25 rows failed. The setup scripts now wait for cluster health and retry 503, 401, 408 and no answer for up to 180 seconds, printing one line per attempt. Any script that runs right after up needs the same loop, on one node too.
Generate the files
The config generator writes the files of this page’s scenarios: one node, with or without Kibana or Dashboards. For the inputs the lab ran, its output is the tested files, apart from a short generated header. Changed values such as memory or host names keep the tested structure and are listed in the file header; a structural change, such as a disabled route or an IPv6 allowlist, marks the output as not tested. Without JavaScript it shows the files of es-single, the scenario this page walks through.
Threat model
Covers: the node is reachable only through Caddy; only listed client addresses reach Caddy’s routes; requests without valid credentials get 401; the app key can read app-* and add documents there and do nothing else: no overwrite, no delete, no other index, no cluster settings, no security APIs; keys expire.
Does not cover: a leaked key or password within its own scope; expensive queries; anything running on the host; Kibana users (their role is their limit); traffic between Caddy and the node, which is plaintext on the Docker bridge.
Anti-patterns
xpack.security.enabled: falsebecause “the proxy handles it”. The bulk row above is why not.- Publishing 9200 “just for debugging”. As far as I know, Docker writes its own firewall rules for published ports, so a host firewall such as ufw may not apply to them; I did not test that, only that an unpublished port is refused.
- Applications using
elastic, or an API key created withoutrole_descriptors. - A top-level
abortnext tohandleblocks, orpath_regexpmatchers in front ofreverse_proxy. - Adding Docker’s bridge range to the allowlist.
remote_ipis the immediate TCP peer; with Docker’s userland proxy, connections from the host’s own loopback may arrive from the bridge gateway, which would let every local process in. That is a hypothesis; not tested, so check what Caddy logs asremote_ipbefore you change the list.
Not tested
- The Caddy IP allowlist with Docker’s userland proxy, and the ufw variant of a published port.
- Kibana refusing
elasticas its connection user: read from Kibana’s source, not run. - Which status, 401 or 403,
app_xgets from Kibana’s console proxy. - Whether Elasticsearch discards a request body that Caddy cut off at the size limit.
- Any limit on expensive queries inside Elasticsearch.
OpenSearch 3.9.0: what differs
os-single and os-single-dashboards run the same Caddy structure and the same rows against OpenSearch 3.9.0. The proxy results matched Elasticsearch: every path variant got 404 from Caddy, a bulk item naming secret got a per-item 403 at the node and through Caddy (os-single-13, -44), a scripted query passed every layer (os-single-45), a 21 MB body got 413 (os-single-46). What differs:
| Topic | OpenSearch 3.9.0 | Row or source |
|---|---|---|
| Certificates for one node | Required. “TLS is optional for the REST layer and mandatory for the transport layer”, with no single-node exception in the docs. The scenario has its own CA, node and admin certificates from openssl. Whether the plugin starts without them on one node was not tested. | TLS settings |
| HTTPS on the node | On. securityadmin.sh “requires SSL/TLS HTTP to be enabled”, so Caddy connects to https://os01:9200 and verifies it against the lab CA. | securityadmin |
| Demo configuration | Off with DISABLE_INSTALL_DEMO_CONFIG=true. The image’s own internal_users.yml still holds demo users with known passwords; the scenario mounts its own users, roles and mappings. admin:admin, kibanaserver:kibanaserver and readall:readall were rejected. | os-single-05 to -07 |
Content-Type | Enforced. A body without it (curl then sends application/x-www-form-urlencoded) gets 406 Content-Type header [application/x-www-form-urlencoded] is not supported. Elasticsearch accepted the same requests. Scripts that work against Elasticsearch can fail on this alone. | first run, os-single-01 and others |
| App credential | No API key. app_x is an internal user mapped to a role with indices:data/write/index, so a bulk index item is accepted (os-single-12). There is no create-only equivalent of create_doc in this lab, so the two engines’ app roles are not equivalent; overwriting by id was not tested. | os-single-12 |
| Dashboards console | Same bypass as Kibana: admin read _cluster/settings through /api/console/proxy, app_x got 403. No internal-origin header needed, only osd-xsrf: true. | os-single-dashboards-69, -70 |
Dashboards /api/status | 401 without credentials, unlike Kibana. | os-single-dashboards-66 |
vm.max_map_count | 262144 minimum; a host set for Elasticsearch satisfies both. | official, install |
The full OpenSearch setup, with the certificate script and securityadmin.sh, is in scenarios/os-single/ in the companion repo.