If you discover a security vulnerability in LocalChat, please report it privately rather than opening a public issue.
- Contact: jw.vander.stam@gmail.com
- Include a description of the issue, steps to reproduce, and the affected commit/version.
- LocalChat is developed on a rolling
mainbranch; only the latest commit onmainis supported — there are no maintained release branches.
The items below are known, deliberately not remediated via the usual route (credential rotation / git history rewrite), and are documented here so a reviewer can establish their status from the repo alone. Reviewed as of 2026-09-16 — re-check every entry against the source when editing this file, and move this date. An entry that is merely old reads exactly like one that is still true.
- What: A PostgreSQL password (
PG_PASSWORD) was committed in plaintext starting with the initial commit (5499093) and several early commits — confirmed viagit log --all -S <value>using the value currently set forPG_PASSWORDin the local, untracked.envfile used for Docker Compose development (intentionally not repeated here — this file is tracked and published, and doing so would make the value more discoverable than it already is, for no verification benefit). - Scope: Used only by the local Docker Compose
db(PostgreSQL) service for local development (seedocker-compose.yml). Never used in any deployed/production environment, and never reused for any other account or system. - Decision — not rotated, not rewritten out of git history:
- Not rotated: it only ever protected a local, loopback-bound Postgres instance with no externally reachable production data — rotation provides negligible security benefit.
- Not rewritten out of history: rewriting history (
git filter-repo/ BFG) breaks every clone, fork, and commit reference for a credential that carries no real-world risk once its exposure is accepted. Disproportionate for this case. - The credential is treated as burned: it must never be reused for any new secret, account, or environment.
- Compensating controls already in place:
.envis git-ignored — the live value is not re-committed by normal use;.env.exampleonly ships a placeholder (PG_PASSWORD=your-password-here).src/config.pyfails closed at startup ifPG_PASSWORDis unset (raise ValueError("PG_PASSWORD must be set in .env file!")) — the app can never silently fall back to a default.docker-compose.yml'sdbservice publishes port 5432 as"${BIND_HOST:-127.0.0.1}:5432:5432", so by default Postgres is bound to localhost only and is not reachable from outside the host, even on a machine with a public IP and no firewall — matching the pattern already used by theapp,ollama,mcp-local-docs,mcp-web-search, andmcp-cloud-connectorsservices in the same file. (ollamapublishes"${BIND_HOST:-127.0.0.1}:${OLLAMA_BIND_PORT:-11434}:11434"so the host-run dev path can reach it; containers use thebackendnetwork and do not need it.)
- Forward-looking control: gitleaks secret scanning now runs in CI (
.github/workflows/gitleaks.yml) and as a local pre-commit hook (.pre-commit-config.yaml) to prevent any new credential leak. Both only scan the push/PR diff or staged changes — never full history — so they never re-encounter this historical leak;.gitleaks.tomldeliberately has no allowlist entry for it (see that file's header comment for why) and only allowlists CI's own non-secret placeholder test credentials.
No longer an accepted risk. python-jose[cryptography] was replaced by PyJWT
(ROADMAP P2-6), which signs and verifies HS256 over the standard library's hmac
and hashlib. It pulls no ecdsa, no rsa and no pyasn1, so the vulnerable code
is not in either lock and not in the image — the risk is removed rather than reasoned
around. The pip-audit --ignore-vuln PYSEC-2026-1325 suppression in
.github/workflows/tests.yml went with it, and that step now runs with no suppressions
at all.
The entry is kept at its number because CHANGELOG.md and four current documents cite
the sections below it by number; renumbering would silently break those references for
a gain of one deleted heading.
- What:
resolve_principal()(src/security_fastapi.py) checks a token'sjtiagainst therevoked_tokensdeny-list (TokensMixin.is_token_revoked,src/db/tokens.py) on every authenticated request._verify_jti_not_revoked()fails closed — if the database is unreachable and the token was not verified in the last 60 seconds, the request is refused with 401 rather than let through. The residual risk is the grace window itself: a token revoked during an outage stays usable for up to 60 seconds after its last successful check. - "Every authenticated request" became true on 2026-09-16. This entry said it before it
was: the check lived in
require_auth()alone, whilecheck_workspace_access()— every document, chat, memory, feedback, annotation and connector route — andrequire_admin_dep()— 31 admin routes — each decoded the token themselves and never asked. A revoked token kept working on all of them until it expired. All three now resolve the caller through one function, which is where the check lives (audit H1). - Why this is accepted: without the cache, any database blip becomes an authentication outage for every logged-in user. The window is bounded, in-process (correct under ADR-1, which fixes this at one node and one process), and the cache is capped at 4096 entries with stale-first eviction so a stream of distinct tokens cannot grow it without limit. A live check always wins over a cached entry, so revocation while the database is healthy takes effect immediately rather than after up to 60 seconds.
- Compensating factor: JWTs are short-lived (
JWT_ACCESS_TOKEN_EXPIRES, default 7200s), so the exposure from a missed revocation is bounded by the token's own expiry regardless of database state. - Re-review trigger: multi-tenant hosting (different trust domain per workspace), where 60 seconds of stale authorisation crosses a tenant boundary rather than staying inside one operator's deployment.
Corrected 2026-08-20. Until this revision, this entry described the opposite behaviour — fail-open, quoting a comment (
# DB unavailable — fail open rather than locking out users) that no longer exists in the source.13cd503(2026-08-07, SEC-2) made revocation fail closed, and the entry's own "re-review trigger" had come to prescribe as future work exactly what had already shipped. It survived two later edits to this file because nothing re-checked it against the code. The statedJWT_ACCESS_TOKEN_EXPIRESdefault was also wrong — 3600s, against 7200s insrc/config.py— which understated by half the very bound this entry leans on. Found by the 2026-08-19 external audit.
- What:
PluginLoader.load_file()(src/tools/plugin_loader.py) loads every.pyfile underplugins/viaimportlib.util.spec_from_file_location+exec_module— genuine Python module execution, not a restricted or sandboxed interpreter. A plugin's top-level code runs with the same OS privileges as the main app: full filesystem access, network access, and (via the services it can import) the same database connection pool. - Why this is accepted: nothing in the loader constrains a plugin, and nothing is claimed to. The plugin contract in
.claude/rules/plugins.md(service/hook boundary, no core imports) is a design, not built — its own status banner says so — and even built it would only describe what a well-behaved plugin does; it could not constrain what an adversarial file placed inplugins/does, because Python has no built-in code sandbox. What ships isplugins/README.md's loader: any.pyin the directory runs. The trust boundary is therefore the filesystem, not the plugin loader: whoever can write to theplugins/directory already has the same privileges as the app process, with or without the plugin system. - Compensating factor:
plugins/is not writable by any unauthenticated or lower-privilege actor in the shipped deployment — it ships as part of the repo/image, not as a runtime-uploadable directory. There is no HTTP endpoint that writes files intoplugins/. - Re-review trigger: if LocalChat ever adds a feature that writes an uploaded or admin-submitted file into
plugins/at runtime (e.g. a "install plugin from URL" admin action), that feature is the point where real sandboxing (subprocess isolation, restricted__builtins__, or a plugin marketplace review step) becomes necessary — the current design is safe only because plugin code is deployment-time, not runtime, content.
- What:
requirements.txtpinsonnxruntime==1.30.0; it was held at 1.28.0 until Dependabot #371 (2026-09-14) moved it anddocker-smokeproved the image still boots. 1.29.0 imports cleanly onpython:3.12-slim, but segfaults (SIGSEGV, exit 139, no traceback) ondhi.io/python:3.12. The dependency arrives transitively viapymupdf-layout; nothing in this codebase imports it directly. - Why this is accepted: the root cause is not identified.
lddon the native module is clean, every library it declares is present, and the shared-library diff between the two bases shows nothing it links against. The pin is a workaround with a recorded reason, not a fix. - Compensating factor:
docker-smokebuilds and boots the image on every PR, so a Dependabot bump back to 1.29.x turns the PR red rather than shipping a container that will not start. - Re-review trigger: a security advisory against the pinned version, or a bump that
turns
docker-smokered — test by building the image and importing it, since neitherpip installnordocker buildwill reveal the problem. The root cause of 1.29.0 is still unidentified, so a later release regressing the same way is not ruled out.
- What:
ENCRYPTION_KEYfield-encrypts OAuth tokens (src/db/oauth_tokens.py), message content (src/db/conversations.py) and long-term memories (src/db/memories.py). It does not cover document text.document_chunks.chunk_text— the column retrieval reads and feeds to the model — is stored in plain text, as isdocuments.content. - Why this is accepted: it cannot be fixed at the field level.
chunk_tsvisGENERATED ALWAYS AS (to_tsvector('simple', chunk_text)) STORED(src/db/connection.py), so encryptingchunk_textremoves the lexical arm of hybrid search entirely — the ciphertext tokenises to nothing. Encryption and full-text search over the same column are mutually exclusive without a searchable-encryption scheme this project has no reason to carry. - Corrected in SEC-4:
documents.contentwas passed throughencrypt()on write, and never decrypted — nothing reads that column back. It protected nothing, because the same text sat in plain text inchunk_textbeside it, and it made the schema read as though document content was encrypted. The call was removed rather than the claim left standing. - Compensating control: disk/volume encryption on the Postgres data directory, which is
where document text at rest is actually defended. The Postgres port binds to
127.0.0.1by default, so the database is not reachable off-host. - Re-review trigger: any move to hosted or multi-tenant deployment, where the disk is not the operator's own — at which point the question is whether retrieval can move to a design that does not need plaintext in the database, not whether to encrypt this column.
- What:
ADMIN_PASSWORDauthenticates a built-inadminaccount that has no user row. Since decision D6 it is a bootstrap credential: it works only while the database holds no live administrator, and an open session stops being administrative the moment one exists. A normal boot seeds a database admin from the same password, so in practice it is withdrawn from the first start. - The residual: when the database cannot answer, whether a real administrator exists is unknown, and this account is treated as available — which is what it has always been, and is the documented way back in when the database is empty or unreachable. So an outage restores a credential that a healthy installation has withdrawn.
- Why accepted: D6 asked for a credential that a real administrator supersedes, not for
the recovery path to be removed, and the two are separable. The exposure is also narrow:
reaching it needs
ADMIN_PASSWORDitself, and with the database down every workspace-scoped route answers 503 regardless, so there is very little to reach. - Before this: the account could not be demoted or disabled by anyone, had no strength
check — the
.env.exampleplaceholder passed production validation — and kept working beside a changed database admin password (audit M1). - Re-review trigger: any deployment where the database is not the operator's own, or the first time this account is wanted as a true break-glass path — at which point the question is option D6-B (keep it, with a strength check and every use logged), not this middle ground.
- What: a connector ingests documents from a source the application can reach, and everything it ingests becomes answerable through retrieval. The configuration therefore decides what the application can read, which makes who may configure one the control, not what the connector does afterwards.
local_folder— global administrator, plus an allowlist. Its path names the server's own filesystem. Both conditions are enforced independently insrc/routes_fastapi/connector_routes.py: creating or reconfiguring one requires a global administrator, and the path must resolve insideCONNECTOR_LOCAL_ROOTS(CONFIGURATION.md), which is empty by default and so disables the type. Paths are resolved withrealpathand compared by whole components, so..and a symlink pointing out of an allowed root both fail.- This entry exists because the controls did not. A September 2026 external audit
reproduced the whole of it: any user could create a workspace, become its owner, create
a
local_folderconnector on/etc, and read it back.ws:ownerwas the only check, and it is not a barrier when any user may create a workspace. Fixed 2026-09-16.
- This entry exists because the controls did not. A September 2026 external audit
reproduced the whole of it: any user could create a workspace, become its owner, create
a
webhookis a public receiver by design — the connector id plus its secret is the whole credential. Closed 2026-09-16 (P1-2): the secret is mandatory, at creation as well as at delivery, must be at least 16 characters, and is compared withhmac.compare_digest. The fetch goes throughsafe_fetch(§9), so it is capped and cannot be pointed at an internal address.s3— removed 2026-09-16 (audit M5, decision D5). It accepted an owner-suppliedendpoint_urland could fall back to the server's own AWS credentials, and it could not run in the shipped image at all, becauseboto3is deliberately not there (ADR-4). A connector that cannot run is not a feature worth guarding.- Re-review trigger: any new connector type whose configuration names something outside
the workspace — a path, a host, a credential — belongs in
_ADMIN_ONLY_TYPESand in this list, and the question to answer first is what a workspace owner could reach with it.
- What:
src/utils/safe_fetch.pyis the one path by which this application retrieves a URL it was handed — a web-search result, or a webhook'sfetch_url. It resolves the hostname, refuses the fetch if any address the name answers with is private, loopback, link-local, reserved, multicast or unspecified, re-validates every redirect hop, and caps the body and the time. - The residual: the connection is then made by name. A DNS entry that answers differently between the check and the connection — rebinding — is not defeated. Closing it means pinning the connection to the validated address, which over HTTPS means taking over certificate verification for every outbound fetch.
- Why accepted: the attacker has to control a DNS zone and win a race measured in
milliseconds, to reach a network where the interesting services already require
credentials. The check that is in place closes the finding that was actually reported: a
name that simply resolves to
10.0.0.5used to be fetched. - Before this: both call sites inspected the hostname string. An IP literal in a private range was refused; a DNS name was waved through, with a comment in the source saying the check could not resolve it. Redirects were followed without a second look, and neither path capped the response (audit M4).
- Re-review trigger: any deployment where the internal network holds something reachable without credentials, or the first time an outbound fetch is made on behalf of an untrusted tenant rather than an operator.
- What: the domain MCP servers (
mcp_servers/,--profile mcp, off by default) authenticate callers with a single shared secret,MCP_AUTH_TOKEN, presented as a bearer token and compared withhmac.compare_digest. They hold no session and no user, so that token is the whole of their access control. Workspace scoping is passed by the caller:searchrequires aworkspace_idand refuses without one. - The residual: anything holding the token can name any workspace. The servers trust the application to pass the workspace it authorised, because they have no way to check — there is no user identity in an MCP call to check it against.
- Why accepted: the servers are off by default, run on the internal
backendnetwork with their ports bound to loopback, and the only intended caller is the application itself. Decision D4 chose to authorise them rather than remove them. - Before this: there was no check at all, and
searchcould not accept a workspace — so withMCP_ENABLED=true, every answer was drawn from every workspace regardless of who asked, and anything that could reach the port got the whole corpus (audit C3). - Re-review trigger: any caller other than this application, or any deployment where the servers are reachable beyond the compose network — at which point the token should become per-caller, and the workspace should be derived from an identity the server can verify rather than accepted from the request.
- What: documents, connector-synced files and web pages are written by someone other
than the person asking, and they reach the model as part of its prompt. Since GR-1a each
source is fenced in a
<document>or<web_result>tag naming it, the fence cannot be closed from inside, and the system prompt says fenced text is information, never instruction. - The residual: that is a request to the model, not an enforcement. Small local models follow it unreliably, so an instruction planted in a document can still shape an answer — and the answer then carries a genuine citation, which is what makes a reader trust it.
- Why accepted: nothing cheaper reduces it further without a second model in the request path, which a CPU-only container cannot afford (ROADMAP GR-1, out of scope). The path on which an injection persists — long-term memory — is closed structurally (GR-1b: memory is extracted from the user's own turns only), and instruction-shaped documents are to be flagged at ingest (GR-1c).
- Re-review trigger: tools that act rather than read, a cloud model in the default path, or a deployment where the people who write the documents are not trusted by the people who ask about them.
- Base images are digest-pinned (
dhi.io/python:3.12and:3.12-dev). A bare tag makes the image's CVE posture unverifiable after the fact — see ADR-3. requirements.txtis pip-compile output fromrequirements.in, pinning the full transitive closure, and is installed by both CI and Docker on every run — so the pins are continuously exercised rather than asserted. It carries no hashes; the reasoning, which is a measurement rather than an oversight, is inrequirements.in's header.pip-auditruns inunit-tests;gitleaks, CodeQL and SonarCloud run on every PR.- The runtime image ships no shell and no package manager, so nothing can install itself into a running container.
- Test tooling is confined to
requirements-dev.in, which the image never installs. The runtime image no longer containspytest,playwright,faker,coverage,responsesorfreezegun(closed 2026-08-26, OPS-1).