Skip to content

[exporter/opensearchexporter] Validate attribute values in dynamic index names#49362

Open
kylehounslow wants to merge 1 commit into
open-telemetry:mainfrom
kylehounslow:fix/opensearch-index-placeholder-sanitization
Open

[exporter/opensearchexporter] Validate attribute values in dynamic index names#49362
kylehounslow wants to merge 1 commit into
open-telemetry:mainfrom
kylehounslow:fix/opensearch-index-placeholder-sanitization

Conversation

@kylehounslow

@kylehounslow kylehounslow commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Description

Validates attribute values before they are substituted into a %{placeholder} index name in the dynamic index resolver. Values are restricted to [a-z0-9_.-] and may not start with . or contain ... A value that fails the check is skipped in favor of the next attribute in the precedence order (item, scope, resource), then the operator-configured fallback, matching the existing missing-key behavior. The configured fallback is operator-controlled and is not validated.

The allowlist rejects the characters OpenSearch documents as forbidden in index names (space, ,, and :"*+/\|?#><) plus uppercase, and additionally rejects leading-dot and .. so a value cannot resolve to a system index (e.g. .kibana).

The leading-dot escalation requires the placeholder to lead the index name (e.g. traces_index: "%{tenant}"); a static prefix like logs-%{tenant} already keeps the result out of the system namespace.

What this does not cover. A well-formed but unauthorized value still resolves. With traces_index: "%{tenant}", an attacker setting tenant=team-b still produces the team-b index, because team-b is a valid index segment. Character validation can't distinguish an authorized tenant from an unauthorized one; cross-tenant isolation needs tenant allowlisting or an immutable prefix.

Link to tracking issue

Testing

  • go test ./... in exporter/opensearchexporter/.
  • New index_resolver_test.go cases cover path traversal (../../system), .., leading dot, forbidden characters (/, \, space), uppercase, and fall-through from an invalid higher-precedence attribute to a valid lower-precedence one.
  • Verified end-to-end against OpenSearch 2.17.1 and 3.7.0 (shell output below).
Full repro steps

Two OpenSearch nodes (security plugin off for plain HTTP):

# docker-compose.yaml
services:
  opensearch2:
    image: opensearchproject/opensearch:2.17.1
    environment: [discovery.type=single-node, "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m", DISABLE_SECURITY_PLUGIN=true, DISABLE_INSTALL_DEMO_CONFIG=true]
    ports: ["9201:9200"]
  opensearch3:
    image: opensearchproject/opensearch:3.7.0
    environment: [discovery.type=single-node, "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m", DISABLE_SECURITY_PLUGIN=true, DISABLE_INSTALL_DEMO_CONFIG=true]
    ports: ["9202:9200"]
docker-compose up -d

Build a collector with this branch's exporter. make otelcontribcol from the repo root is the official path (its genotelcontribcol step adds a local replace for every module). For faster single-exporter iteration, a minimal module wiring the OTLP receiver, debug exporter, and this exporter with a replace to the local tree works too.

Collector config. traces_index: "%{tenant}" makes the index name fully attacker-controlled, which is what exposes the leading-dot system-index case:

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4328
exporters:
  debug: { verbosity: basic }
  opensearch:
    http:
      endpoint: http://localhost:9202   # or :9201 for the 2.x node
    traces_index: "%{tenant}"
    traces_index_fallback: "rejected-traces"
    mapping:
      mode: ss4o
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug, opensearch]

Send a span carrying the tenant value under test (swap the value per case):

{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          { "key": "service.name", "value": { "stringValue": "victim" } },
          { "key": "tenant", "value": { "stringValue": "../../system" } }
        ]
      },
      "scopeSpans": [
        {
          "scope": { "name": "repro" },
          "spans": [
            {
              "traceId": "5b8efff798038103d269b633813fc60c",
              "spanId": "eee19b7ec3c1b174",
              "name": "span",
              "kind": 1,
              "startTimeUnixNano": "1700000000000000000",
              "endTimeUnixNano": "1700000000000000010"
            }
          ]
        }
      ]
    }
  ]
}

Send the span (swap the tenant value per case), then list indices:

curl -s -X POST http://localhost:4328/v1/traces -H 'Content-Type: application/json' -d @span.json
curl -s "http://localhost:9202/_cat/indices?h=index,docs.count"

Sending team-a, team-b, .kibana_x, ../../system in turn, without the fix:

  tenant=team-a       HTTP 200
  tenant=team-b       HTTP 200
  tenant=.kibana_x    HTTP 200
  tenant=../../system HTTP 500
  -- indices --
  .kibana_x          1     # leading dot created a system-namespace index
  team-a             1
  team-b             1     # cross-tenant write

../../system returns HTTP 500; the exporter logs invalid_index_name_exception (the / is a forbidden index char) and drops the span. The same four with the fix:

  tenant=team-a       HTTP 200
  tenant=team-b       HTTP 200
  tenant=.kibana_x    HTTP 200
  tenant=../../system HTTP 200
  -- indices --
  rejected-traces    2     # .kibana_x and ../../system both routed here
  team-a             1
  team-b             1     # still resolves: cross-tenant gap (see #49225)

Documentation

  • README dynamic-indexing section documents the value restriction and fall-through behavior.
  • .chloggen/opensearchexporter-sanitize-index-placeholders.yaml added.

  • I, a human, wrote this pull request description myself.

…dex names

Attribute values substituted into a %{placeholder} index name are now
restricted to [a-z0-9_.-] and may not start with "." or contain "..". A value
that fails the check is skipped in favor of the next attribute in the
precedence order, then the configured fallback, matching existing missing-key
behavior. This prevents attacker-controlled attribute values from redirecting
writes to system indices (e.g. .kibana) or other indices via path traversal.
The operator-configured fallback is trusted and not validated.

The allowlist follows OpenSearch's documented index naming rules (lowercase
only; no spaces, commas, or the characters :"*+/\|?#><).

Fixes open-telemetry#49225

Assisted-by: Claude Opus 4.8
@kylehounslow kylehounslow marked this pull request as ready for review June 29, 2026 23:58
@kylehounslow kylehounslow requested a review from a team as a code owner June 29, 2026 23:58
@kylehounslow kylehounslow requested a review from mx-psi June 29, 2026 23:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants