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.
Architecture: Where Auth Lives
Section titled “Architecture: Where Auth Lives”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.
Status
Section titled “Status”| Item | Where |
|---|---|
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 endpoints | Frontend |
OpenSearch + Dashboards bound to 127.0.0.1 (legacy host) | OpenSearch |
UFW blocks :9200 externally on legacy host | Host |
Backend proxy at /api/v1/analytics/** (passthrough) | Backend |
OpenSearchRequestValidator blocks destructive paths + SQL/PPL | Backend |
OpenSearchBulkValidationService for _bulk uploads | Backend |
Optional OPENSEARCH_USERNAME/PASSWORD on semantic-api, ai-service, mcp-server | Services |
Apache security headers + CSP Report-Only on analytics.logrite.ai | Apache |
Data Prepper Dockerfile pinned (no more :latest) | Infra |
| Item | Notes |
|---|---|
Frontend cutover from analyticsapi.logrite.ai → leapfrogapi.deployd.io/api/v1/analytics | Add VITE_ANALYTICS_PROXY_API in Coolify env, rebuild frontend |
JWT enforcement on /api/v1/analytics/** | Set OPENSEARCH_PROXY_ENFORCE_JWT=true after frontend cutover is verified |
| OpenSearch Security Plugin (users/roles via Dashboards UI) | Requires Coolify migration to opensearchproject/opensearch:3.3.1 |
| Move chat history to backend (or encrypt in localStorage) | 4 keys under opensearch_chat_* |
| Item | Notes |
|---|---|
| CSP enforce mode (after Report-Only review) | Tune from violation reports |
Rate limiting (Apache mod_evasive or Cloudflare WAF) | Currently nothing |
Cloudflare Access on analyticsapi.logrite.ai | Audit-only mode first |
| Daily snapshots of OpenSearch indexes | opensearch-mgr snapshot |
Backend Proxy: How It Works
Section titled “Backend Proxy: How It Works”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
Authorizationheader before forwarding (the JWT is for Spring Security, not OpenSearch) - Adds basic auth from
OPENSEARCH_USERNAME/OPENSEARCH_PASSWORDif 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
Configuration
Section titled “Configuration”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.
Validator Rules
Section titled “Validator Rules”OpenSearchRequestValidator denies:
PUT/POST/DELETE/PATCHto/_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_)
Apache Headers (analytics.logrite.ai)
Section titled “Apache Headers (analytics.logrite.ai)”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.
Service Credentials
Section titled “Service Credentials”When the Security Plugin is enabled, every OpenSearch client needs credentials. The env-var contract is the same across all services:
| Service | Env var | File |
|---|---|---|
| Backend (Logrite-AI) | OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD | application.yml |
| semantic-api | OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD | src/config/index.ts |
| ai-service | OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD | src/config/index.ts |
| mcp-server | OPENSEARCH_USERNAME / OPENSEARCH_PASSWORD | config.py |
| Dashboards | OPENSEARCH_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.
Role Permissions: logrite_app_role
Section titled “Role Permissions: logrite_app_role”The shared service-account role used by backend / semantic-api / ai-service / mcp-server. Two index-permission scopes:
| Pattern | Actions | Why |
|---|---|---|
log-*, trace-*, payment_*, external_*, database_*, exceptions*, *_semantic | indices_all | Logrite-owned data — full read/write |
* (cluster-wide) | indices:admin/get, indices:admin/exists, indices:admin/mappings/get, indices:monitor/settings/get, indices:monitor/stats | Index 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:
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.