diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b4cca4..5849258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ Changes in this section will be promoted to a dated release entry on merge to `m ### Security +- **anythingllm-docker — embed widget can no longer be left callable from any website (2026-07-22)** — the public chat widget is the customer's brand and their capped LLM budget, and AnythingLLM treats an embed with **no** allowlist as *allow every origin* (`EmbedConfig.parseAllowedHosts` returns `null` → `canRespond` skips the origin check). Two changes close that: (1) `EMBED_REQUIRE_ALLOWLIST: "true"` in `compose.prod.yaml` flips "no allowlist" to **deny**-all, so the failure mode is a visibly dead widget instead of a stranger silently spending the customer's budget (upstream #5759; effective on images built after 2026-06-17, ignored harmlessly on older ones); (2) the customer dashboard gains an **Authorized websites** panel (`GET`/`POST /app/api/embed-domains`) so adding a second site never needs an operator — entries are normalised to `scheme://host[:port]` (lower-cased, path/trailing-slash stripped, wildcards refused, since ALLM compares the browser `Origin` byte-for-byte), the instance's own origin is always retained so the list can never be emptied, and the list is persisted to a new `dashboard_state` volume. The allowlist that provisioning seeds arrives as `EMBED_ALLOWLIST_DOMAINS`. **Compliance**: NIST CSF 2.0 PR.AC-4, PR.DS-2; CIS v8 4.1. **Verification**: 16-check live test of the dashboard (public health probe, auth gate, CSRF gate, wildcard rejection, case/path normalisation, self-origin retention, empty-submission floor, state-file persistence, comma-separated-string payload shape, snippet contents) all pass against a running server + stub AnythingLLM; ALLM's own `validatedCreationData` was executed directly to confirm an array-valued `allowlist_domains` stores `NULL` (allow-all) while the comma-separated string stores the array. + +- **anythingllm-docker — the embed snippet now carries the AI disclaimer (2026-07-22)** — `/app/api/snippet` emits `data-greeting` ("I'm an AI assistant… general information only… don't share private information in this chat") plus `data-no-sponsor`, so the PRD's non-negotiable disclosure is stated the moment the widget opens rather than depending on the model remembering to say it. + - **anythingllm-docker — per-instance Infisical secret paths (`infisical_secret_path`) (2026-07-19)** — supports the WeOwn-Chat initiative secret model: ONE Infisical project per initiative, one **folder per instance** (`/sites/`), each droplet Machine Identity restricted to its own folder via a path-scoped project role — the client-isolation boundary (D-ISO amendment) without a project per customer. New copier var `infisical_secret_path` (default `/` — fully backward-compatible; existing dedicated-project sites render identically) threads through every secret consumer: container entrypoint (`entrypoint-infisical.sh` `infisical run --path`), `ansible/deploy.yml` (new `infisical_path` var from `INFISICAL_PATH` env; all five `infisical run`/`secrets get` calls), `backup.sh`/`restore.sh` (local + remote-ssh modes), `bootstrap-product.sh`, `deploy.sh` env pass-through, and `scripts/provision-openrouter-key.sh` (new `--path`/`--operator-path` flags — the OpenRouter PROVISIONING key can now live at the project root where instance-scoped MIs **cannot** read it, closing the POC caveat that a compromised droplet could mint keys). **Verification**: `bash -n` clean on the shell scripts; rendered-template validation via the fleet dry run (weown-fleet `render-deploy.sh`). - **anythingllm-docker — CIS host hardening play + encrypted-at-rest data volume (2026-07-15)** — Phase A of porting the SOP-INFRA-012 baseline to customer instances. **Hardening:** new `template/ansible/harden.yml` applies DevSec `os_hardening` + `ssh_hardening` (≈ CIS Ubuntu L1 — kernel/sysctl, auth policy, SSH key-only + strong KEX/ciphers) with the Docker-safe `ip_forward=1` task-var override (defaults-level overrides silently lose to inner-role defaults — the 2026-07-03 no-egress landmine), then measures with **Lynis** and verifies Docker survived; `ansible/requirements.yml` pins the `devsec.hardening` collection. Phase A deliberately keeps root key-only SSH (all fleet tooling SSHes as `root@`); Phase B (ops-user model, root-off, per SOP-INFRA-012) is gated on fleet-tooling migration and tracked for pre-first-customer. **Data at rest:** new `digitalocean_volume` (default 50 GB, `prevent_destroy`, `data_volume_size_gb=0` to disable) — DO block storage is platform-encrypted at rest, droplet local disk is not; cloud-init `setup-data-volume.sh` waits out the attach race, formats/mounts, points the **Docker data-root** (chats/vectors/uploads) and the **backups dir** at it before Docker installs. **Compliance**: FTC Safeguards/GLBA at-rest expectation; NIST CSF 2.0 PR.DS-1, PR.PS; CIS v8 4.1/3.11. **Verification**: rendered `harden.yml` passes `ansible-playbook --syntax-check`; `setup-data-volume.sh` `bash -n` clean; rendered `main.tf` parses (`tofu fmt`). @@ -92,6 +96,8 @@ Changes in this section will be promoted to a dated release entry on merge to `m ### Fixed +- **anythingllm-docker dashboard — `/app/healthz` answered 401, so every deploy reported a scary false warning (2026-07-22)** — the dashboard's public health route was checked *before* the base path was stripped, so it only ever matched the container-internal `/healthz`; Caddy routes only `/app*` to the dashboard, which meant the public probe fell through to the auth gate. Added the health check after base-path stripping too, leaving the container healthcheck's `/healthz` working unchanged. + - **`anythingllm-docker` terraform — `ssh_key_fingerprint(s)` name/type regression broke every fresh provision (2026-07-15)** — the 12-PR consolidation (`e8496d1`) pluralized the template var to `ssh_key_fingerprints` / `list(string)`, but `weown-tofu` provides the singular `TF_VAR_ssh_key_fingerprint` (one shared DO bootstrap key; team access is post-boot via `OPS_AUTHORIZED_KEYS`), and the live sites (`s004`) use the singular `string`. Result: `tofu plan` failed with `No value for required variable ssh_key_fingerprints`. Reverted the template (`variables.tf`, `main.tf` → `ssh_keys = [var.ssh_key_fingerprint]`, `terraform.tfvars.example`) to the singular convention that matches live + `weown-tofu`. **openclaw-docker intentionally keeps the plural** (different folder-based `weown-tofu` structure; its var is designed as a dev-key list). Another consolidation regression never live-tested until the first `weown-fleet` provisioning run. **Verification**: rendered terraform parses (`tofu fmt`); no `ssh_key_fingerprints` refs remain in the template. - **`itofu.sh` — M2 regression: `plan`/`apply`/`output` failed on the S3 backend (2026-07-15)** — closes handoff **H1** (the M2 argv→env change was static-verified only; `copier` wasn't installed in the agent env, so the tofu path was never live-tested). The M2 change added `export AWS_ACCESS_KEY_ID/SECRET` (from `TF_VAR_spaces_access_key/secret`) to the `init` case so the DO Spaces S3 backend could authenticate — but **not** to `plan`, `apply`, or the passthrough (`output`/`destroy`) cases. Those creds are deliberately not persisted in `.terraform`, so every state op needs them in env; `tofu plan` therefore failed with `No valid credential sources found ... no EC2 IMDS role`. Fixed by routing all non-init tofu calls through a `trun()` helper that exports the AWS_* creds (`sse_customer_key` stays init-only — it *is* persisted). Applied to both templates carrying this wrapper: `anythingllm-docker` and `openclaw-docker`. **Surfaced by** the first live provisioning run of the fleet orchestrator (`weown-fleet`). **Verification**: rendered `bash -n` + `shellcheck -S error` clean on both. - **`deploy-new-site.sh` — automated AnythingLLM deploys now bootable (2026-07-15)** — the script pushed `JWT_SECRET`/`ADMIN_EMAIL`/Spaces creds but none of the other fail-loud-required AnythingLLM values, so an automated deploy produced a container that refused to boot (the manual `DEPLOYMENT_GUIDE.md` flow was unaffected). New `anythingllm-docker` branch in Phase 2 pushes `ANYTHINGLLM_IMAGE` (from env — the private registry namespace is never committed), `EMBEDDING_ENGINE` (default `openrouter`) + optional `EMBEDDING_MODEL_PREF`, and mints the per-customer capped `OPENROUTER_API_KEY` via `scripts/provision-openrouter-key.sh` (cap override `OPENROUTER_LIMIT_USD`, default $50); loud warnings when a value can't be set automatically. Also adds a "Compliance & data handling" facts table to [`docs/CUSTOMER_INSTANCE_PROVISIONING.md`](docs/CUSTOMER_INSTANCE_PROVISIONING.md) (tenancy, locality, encryption, retention, subprocessors, framework mapping) backing the customer-facing terms. **Verification**: `bash -n` + `shellcheck` clean. diff --git a/anythingllm-docker/template/dashboard/public/index.html b/anythingllm-docker/template/dashboard/public/index.html index 1c32253..af64a81 100644 --- a/anythingllm-docker/template/dashboard/public/index.html +++ b/anythingllm-docker/template/dashboard/public/index.html @@ -17,6 +17,7 @@ ul{list-style:none;padding:0;margin:.4rem 0}li{display:flex;justify-content:space-between;align-items:center;padding:.45rem .6rem;border-bottom:1px solid #22304f;font-size:.9rem} li button{background:none;border:0;color:var(--e);cursor:pointer} .up{display:flex;gap:.6rem;margin-top:.6rem}.up input[type=file]{flex:1;color:var(--mut)} +#domnew{flex:1;padding:.5rem;border-radius:8px;border:1px solid #2a3a5c;background:#0e1726;color:#eef2f7} button.act{background:var(--p);color:#fff;border:0;border-radius:8px;padding:.5rem .9rem;cursor:pointer;font-weight:600} #chatlog{height:320px;overflow-y:auto;background:#0e1726;border-radius:8px;padding:.8rem;font-size:.9rem;display:flex;flex-direction:column;gap:.5rem} .me{align-self:flex-end;background:#1d4ed8;padding:.4rem .7rem;border-radius:10px;max-width:80%} @@ -45,6 +46,11 @@
+

Websites allowed to use your chatbot

+

Your chatbot only answers on the websites listed here. Add a site before you paste the snippet into it — anywhere else, the widget stays silent.

+ +
+

Embed on your site

Paste this snippet before </body> on your website:

loading…
@@ -68,6 +74,21 @@

Embed on your site

Paste this sni const d=await j('POST','/api/chat',{message:t}); log.insertAdjacentHTML('beforeend',`

${(d.text||'…').replace(/`);log.scrollTop=1e9;} async function logout(){await j('POST','/api/logout');location.reload();} +// Authorised websites. The instance's own address is always allowed and cannot +// be removed here — the list must never be empty, or the widget would answer on +// any site at all. +let DOMS=[],ALWAYS=[]; +const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +function paintDomains(){const el=document.getElementById('domlist'); +el.innerHTML=DOMS.map(d=>`
  • ${esc(d)}${ALWAYS.includes(d)?'always allowed':``}
  • `).join('')||'
  • No websites yet.
  • ';} +async function loadDomains(){const d=await j('GET','/api/embed-domains');DOMS=d.domains||[];ALWAYS=d.always||[];paintDomains();} +async function saveDomains(next){const msg=document.getElementById('dommsg');msg.textContent='saving…'; +const d=await j('POST','/api/embed-domains',{domains:next}); +if(d.error){msg.textContent='✗ '+d.error;return;} +DOMS=d.domains||[];paintDomains(); +msg.textContent=d.persisted?'✓ saved':'✓ saved (note: this list will reset on redeploy — tell WeOwn support)';} +function addDomain(){const i=document.getElementById('domnew');const v=i.value.trim();if(!v)return;i.value='';saveDomains(DOMS.concat([v]));} +function delDomain(d){saveDomains(DOMS.filter(x=>x!==d));} j('GET','/api/snippet').then(d=>document.getElementById('snippet').textContent=d.snippet||d.note||''); -showTab();docs('public');docs('private'); +showTab();docs('public');docs('private');loadDomains(); diff --git a/anythingllm-docker/template/dashboard/server.js b/anythingllm-docker/template/dashboard/server.js index b54a86a..0d67f76 100644 --- a/anythingllm-docker/template/dashboard/server.js +++ b/anythingllm-docker/template/dashboard/server.js @@ -15,7 +15,8 @@ // Required env (Infisical): ALLM_ADMIN_API_KEY, DASHBOARD_PASSWORD_HASH // (sha256 hex of the customer password), DASHBOARD_SESSION_SECRET. // Optional env: DASHBOARD_CUSTOMER_EMAIL, WS_PUBLIC_SLUG, WS_PRIVATE_SLUG, -// EMBED_ID, PUBLIC_DOMAIN, ALLM_URL, PORT, BASE_PATH. +// EMBED_ID, EMBED_ALLOWLIST_DOMAINS, PUBLIC_DOMAIN, ALLM_URL, PORT, BASE_PATH, +// DASHBOARD_STATE_DIR, UPLOAD_ALLOWED_EXT, UPLOAD_MAX_BYTES. 'use strict'; const http = require('http'); const https = require('https'); @@ -49,6 +50,42 @@ const EMBED_ID = process.env.EMBED_ID || ''; const PUBLIC_DOMAIN = process.env.PUBLIC_DOMAIN || ''; const VERSION = process.env.DASHBOARD_VERSION || 'v0'; +// ── embed domain allowlist ─────────────────────────────────────────────────── +// AnythingLLM will answer an embed from ANY website unless the embed carries an +// allowlist (a NULL allowlist means allow-all), so the customer's bot — and +// their capped LLM budget — is only theirs once this list is set. The list is +// seeded by provisioning (EMBED_ALLOWLIST_DOMAINS) and owned by the customer +// from their first save here, so adding a second website never needs an +// operator. It can never be emptied: this instance's own origin is always in it. +// +// ALLM compares the browser's Origin header to the stored strings EXACTLY, so +// every entry must be "scheme://host[:port]" — lower-cased, no path, no trailing +// slash, no wildcards (ALLM does not support them). +const STATE_DIR = process.env.DASHBOARD_STATE_DIR || '/state'; +const DOMAINS_FILE = path.join(STATE_DIR, 'embed-domains.json'); +const normOrigin = (raw) => { + let s = String(raw || '').trim().toLowerCase(); + if (!s) return null; + if (!/^https?:\/\//.test(s)) s = `https://${s}`; + let u; try { u = new URL(s); } catch { return null; } + if (!u.hostname.includes('.') || /[^a-z0-9.-]/.test(u.hostname)) return null; + return `${u.protocol}//${u.hostname}${u.port ? `:${u.port}` : ''}`; +}; +const dedupe = (list) => [...new Set(list.filter(Boolean))]; +const SELF_ORIGIN = normOrigin(PUBLIC_DOMAIN); +const seedDomains = () => dedupe((process.env.EMBED_ALLOWLIST_DOMAINS || '').split(',').map(normOrigin)); +const readDomains = () => { + try { return dedupe(JSON.parse(fs.readFileSync(DOMAINS_FILE, 'utf8')).domains.map(normOrigin)); } + catch { return seedDomains(); } +}; +const writeDomains = (list) => { + try { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(DOMAINS_FILE, JSON.stringify({ domains: list, savedAt: new Date().toISOString() }, null, 2)); + return true; + } catch (e) { console.error('[dashboard] could not persist embed domains:', e.message); return false; } +}; + // ── upload policy (locked-down release) ────────────────────────────────────── // The uploader is the authenticated PRACTICE OWNER uploading their OWN grounding // documents (service guide, fee schedule, Q&A sheet) — per the PRD the supported @@ -139,6 +176,10 @@ const server = http.createServer(async (req, res) => { if (p === '/healthz') return send(res, 200, { ok: true }); if (!p.startsWith(BASE)) return send(res, 302, 'redirecting', { Location: BASE + '/' }); p = p.slice(BASE.length) || '/'; + // Public health probe. Caddy only routes /app* here, so `/healthz` above is + // unreachable from outside the compose network — a deploy check against the + // public URL has to be able to hit BASE + /healthz, before any auth. + if (p === '/healthz') return send(res, 200, { ok: true }); const authed = validSession(req.headers.cookie); // CSRF: all state-changing calls must carry the custom header (fetch-only) @@ -217,11 +258,42 @@ const server = http.createServer(async (req, res) => { return send(res, 200, { text, sources: (r.json && r.json.sources || []).map((s) => s.title).slice(0, 5) }); } + // ── authorised websites (the embed's domain allowlist) ── + if (p === '/api/embed-domains' && req.method === 'GET') { + return send(res, 200, { domains: readDomains(), always: SELF_ORIGIN ? [SELF_ORIGIN] : [], configured: !!EMBED_ID }); + } + if (p === '/api/embed-domains' && req.method === 'POST') { + if (!EMBED_ID) return send(res, 400, { error: 'the chat widget is not provisioned yet — contact WeOwn support' }); + const body = await readBody(req); + const input = Array.isArray(body.domains) ? body.domains : []; + const bad = input.filter((d) => String(d || '').trim() && !normOrigin(d)); + if (bad.length) return send(res, 400, { error: `not a usable website address: ${bad.slice(0, 3).join(', ')} — use the form example.com (no wildcards, no page paths)` }); + // Self-origin is always retained, so the list can never become empty — + // an empty allowlist is exactly the allow-any-site state this prevents. + const list = dedupe([SELF_ORIGIN, ...input.map(normOrigin)]); + if (!list.length) return send(res, 400, { error: 'at least one website is required' }); + const r = await allm('POST', `/api/v1/embed/${encodeURIComponent(EMBED_ID)}`, + { body: { allowlist_domains: list.join(',') }, headers: { 'content-type': 'application/json' } }); + if (r.status !== 200 || !(r.json && r.json.success)) { + return send(res, 502, { error: 'could not update the allowed websites', detail: (r.json && r.json.error) || r.status }); + } + const persisted = writeDomains(list); + return send(res, 200, { ok: true, domains: list, persisted }); + } + // ── embed snippet ── if (p === '/api/snippet' && req.method === 'GET') { if (!EMBED_ID) return send(res, 200, { snippet: '', note: 'Embed not provisioned yet — contact WeOwn support.' }); const d = PUBLIC_DOMAIN || 'YOUR-INSTANCE-DOMAIN'; - return send(res, 200, { snippet: `