Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>`), 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`).
Expand Down Expand Up @@ -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.
Expand Down
23 changes: 22 additions & 1 deletion anythingllm-docker/template/dashboard/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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%}
Expand Down Expand Up @@ -45,6 +46,11 @@
<div class="banner pub">Everything on this tab is PUBLIC β€” hours, services, FAQs. Never upload client or personal data here.</div>
<ul id="pubdocs"></ul>
<div class="up"><input type="file" id="pubfile" accept=".pdf,.md,.markdown,.txt,.csv"><button class="act" onclick="up('public')">Upload public doc</button></div><div class="okmsg" id="pubmsg"></div>
<h2 style="margin-top:1rem">Websites allowed to use your chatbot</h2>
<p class="sub">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.</p>
<ul id="domlist"></ul>
<div class="up"><input id="domnew" placeholder="example.com" onkeydown="if(event.key==='Enter')addDomain()"><button class="act" onclick="addDomain()">Add website</button></div>
<div class="okmsg" id="dommsg"></div>
<h2 style="margin-top:1rem">Embed on your site</h2><p class="sub">Paste this snippet before <code>&lt;/body&gt;</code> on your website:</p>
<pre id="snippet">loading…</pre>
</div>
Expand All @@ -68,6 +74,21 @@ <h2 style="margin-top:1rem">Embed on your site</h2><p class="sub">Paste this sni
const d=await j('POST','/api/chat',{message:t});
log.insertAdjacentHTML('beforeend',`<div class="bot">${(d.text||'…').replace(/</g,'&lt;')}</div>`);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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function paintDomains(){const el=document.getElementById('domlist');
el.innerHTML=DOMS.map(d=>`<li><span>${esc(d)}</span>${ALWAYS.includes(d)?'<span class="note">always allowed</span>':`<button onclick="delDomain('${esc(d)}')">remove</button>`}</li>`).join('')||'<li class="note">No websites yet.</li>';}
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();
</script></body></html>
76 changes: 74 additions & 2 deletions anythingllm-docker/template/dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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: `<script data-embed-id="${EMBED_ID}"\n data-base-api-url="https://${d}/api/embed"\n src="https://${d}/embed/anythingllm-chat-widget.min.js"><\/script>` });
// data-greeting carries the AI disclaimer the moment the widget opens, so
// it is stated before the visitor types anything (PRD Β§3, non-negotiable).
const greeting = "Hi! I'm an AI assistant. Everything here is general information only β€” please verify it for your own situation, and don't share private information in this chat.";
return send(res, 200, { snippet:
`<script data-embed-id="${EMBED_ID}"\n` +
` data-base-api-url="https://${d}/api/embed"\n` +
` data-greeting="${greeting}"\n` +
` data-no-sponsor="true"\n` +
` src="https://${d}/embed/anythingllm-chat-widget.min.js"><\/script>` });
}

return send(res, 404, { error: 'not found' });
Expand Down
Loading
Loading