Skip to content

Security Hardening

This page tracks the security posture of the platform. It evolves as items ship. The 2026-04-17 round adds a backend OpenSearch proxy, optional auth across services, Apache security headers, and Dockerfile pinning.

Browser ──► leapfrogapi.deployd.io /api/v1/analytics/** ──► OpenSearch
(validates JWT, (basic auth,
service credentials) Security Plugin)

The frontend no longer needs OpenSearch credentials. All OpenSearch traffic flows through the Spring Boot backend, which validates the user’s JWT and forwards with a service account.

ItemWhere
DOMPurify on dangerouslySetInnerHTML (5 sites)Frontend
Auth tokens in memory + an HttpOnly session vault on the app origin — not in localStorage or sessionStorage (F-13/F-35, 2026-10-01; see services/frontend)Frontend
App CSP enforced without 'unsafe-inline'/'unsafe-eval' in script-src; inline theme script allowed by a build-time hash; report-uri /csp-report (F-12, 2026-10-01)Frontend nginx
OpenSearch query escaping (alerting + anomaly detection)Frontend
Bearer token on chat assistant endpointsFrontend
OpenSearch + Dashboards bound to 127.0.0.1 (legacy host)OpenSearch
UFW blocks :9200 externally on legacy hostHost
Backend proxy at /api/v1/analytics/** (passthrough)Backend
OpenSearchRequestValidator blocks destructive paths + SQL/PPLBackend
OpenSearchBulkValidationService for _bulk uploadsBackend
Optional OPENSEARCH_USERNAME/PASSWORD on semantic-api, ai-service, mcp-serverServices
Apache security headers + CSP Report-Only on analytics.logrite.aiApache
Data Prepper Dockerfile pinned (no more :latest)Infra

The proxy lives at controller/OpenSearchProxyController.java:

  • Matches /api/v1/analytics/** — and the legacy /api/v1/opensearch/**, which the same controller still answers so an old frontend build keeps working — for GET / POST / PUT / DELETE / HEAD / OPTIONS / PATCH
  • Strips the user’s Authorization header before forwarding (the JWT is for Spring Security, not OpenSearch)
  • Adds basic auth from OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD if both are set
  • Runs every request through OpenSearchRequestValidator
  • Bulk requests go through OpenSearchBulkValidationService (NDJSON shape, size cap, max docs, system index check)
  • Forwards upstream response bytes verbatim with hop-by-hop and CORS headers stripped

application.yml:

opensearch:
host: ${OPENSEARCH_HOST:http://localhost:9200}
username: ${OPENSEARCH_USERNAME:}
password: ${OPENSEARCH_PASSWORD:}
connect-timeout-ms: 5000
read-timeout-ms: 60000
proxy:
max-bulk-bytes: 52428800 # 50MB
max-bulk-docs: 10000
enforce-jwt: ${OPENSEARCH_PROXY_ENFORCE_JWT:false}

The enforce-jwt flag is off by default so the proxy ships in passthrough mode — frontend can cut over without auth changes. Flip to true once Security Plugin and frontend cutover are both verified.

OpenSearchRequestValidator denies:

  • PUT/POST/DELETE/PATCH to /_cluster/settings, /_nodes, /_security, /_snapshot, /_template
  • Wildcard DELETE / (matches ^/[*_,\-]+$)
  • SQL/PPL bodies containing DROP|DELETE|ALTER|TRUNCATE|CREATE|INSERT|UPDATE|GRANT|REVOKE

OpenSearchBulkValidationService denies:

  • Body > max-bulk-bytes
  • Doc count > max-bulk-docs
  • Non-NDJSON Content-Type
  • Action lines targeting system indexes (starting with . or _)

Historical — not in the path today. This was /root/logrite/config/apache/analytics.logrite.ai.conf on the pre-2026-08-03 host. Apache is not running on either VM now: the Cloudflare tunnel maps analytics.logrite.ai straight to the Dashboards host port, so these headers are not applied. It required a2enmod headers once on the host. CSP is in Report-Only initially — violations log to /csp-report but nothing is blocked. Once stable, swap to Content-Security-Policy to enforce.

When the Security Plugin is enabled, every OpenSearch client needs credentials. The env-var contract is the same across all services:

ServiceEnv varFile
Backend (Logrite-AI)OPENSEARCH_USERNAME / OPENSEARCH_PASSWORDapplication.yml
semantic-apiOPENSEARCH_USERNAME / OPENSEARCH_PASSWORDsrc/config/index.ts
ai-serviceOPENSEARCH_USERNAME / OPENSEARCH_PASSWORDsrc/config/index.ts
mcp-serverOPENSEARCH_USERNAME / OPENSEARCH_PASSWORDconfig.py
DashboardsOPENSEARCH_USERNAME / OPENSEARCH_PASSWORD (or kibanaserver user)opensearch_dashboards.yml

All clients fall back to no-auth when the vars are unset, so this can roll out before the Security Plugin is enabled. See opensearch/security-plugin for how to manage users in the Dashboards UI.

The shared service-account role used by backend / semantic-api / ai-service / mcp-server. Two index-permission scopes:

PatternActionsWhy
log-*, trace-*, payment_*, external_*, database_*, exceptions*, *_semanticindices_allLogrite-owned data — full read/write
* (cluster-wide)indices:admin/get, indices:admin/exists, indices:admin/mappings/get, indices:monitor/settings/get, indices:monitor/statsIndex discovery — needed by semantic-api’s listIndexes() (_cat/indices + _mapping walk)

The cluster-wide * actions are monitor-only — no read/write, no admin mutations. They exist solely so the UI’s project/index dropdowns can enumerate indexes. Without indices:monitor/settings/get and indices:monitor/stats, _cat/indices returns 403 and the dropdowns come up empty.

Applying role changes (HTTP-unencrypted OS)

Section titled “Applying role changes (HTTP-unencrypted OS)”

securityadmin.sh is not usable on this cluster — it requires TLS on either 9200 or 9300, but plugins.security.ssl.http.enabled is false (TLS terminates at Traefik). Apply role changes via the security plugin’s REST API instead:

Terminal window
OS_C=$(sudo docker ps --format '{{.Names}}' | grep -iE 'y0w8|opensearch' | grep -v dashboard | head -1)
ADMIN_PW=$(sudo docker exec "$OS_C" env | grep ^OPENSEARCH_INITIAL_ADMIN_PASSWORD | cut -d= -f2)
sudo docker exec "$OS_C" curl -s -X PUT -u admin:"$ADMIN_PW" \
-H 'Content-Type: application/json' \
-d @role-body.json \
http://127.0.0.1:9200/_plugins/_security/api/roles/<role-name>

PUT is a full replace, not a patch — include every cluster_permissions, index_permissions, and tenant_permissions block. Keep the disk roles.yml mirror in sync (Coolify mounts /home/victor/logrite/config/opensearch/security/ into the OS container) so a bootstrap reload after a security-index wipe doesn’t silently regress.