Skip to content

security: audit lot 1 — XSS, unauth API, shell allowlist, git tool (+ example compose) - #12

Merged
Kurtisone merged 5 commits into
mainfrom
security-lot1
Aug 12, 2026
Merged

security: audit lot 1 — XSS, unauth API, shell allowlist, git tool (+ example compose)#12
Kurtisone merged 5 commits into
mainfrom
security-lot1

Conversation

@Kurtisone

Copy link
Copy Markdown
Owner

First batch of fixes from the security audit of main (post-merge
v3.11). Each fix is atomic and lands with its own tests. Full suite
green (406 tests), ruff check + format clean.

Fixes

C-3 — Stored XSS in the UI markdown renderer

escapeHtml escaped only &/</>, but its output lands in
attribute position (link href, the trace/drawer templates).
[x](" autofocus onfocus="location=name) therefore rendered as an
<a> with an injected event handler that fired with no interaction.
The text reaching the renderer includes tool output (web_fetch/research
page content, files:read, sysadmin logs), so a hostile page was enough
to reach it — and the API token lives in localStorage, so this was
token theft, not a cosmetic bug.

Three layers: escape "/', validate link schemes against an
allowlist (anything else downgraded to plain text), and a CSP on
GET /. 'unsafe-inline' is still required by the single-file UI, so
the CSP does not stop injected script from running — but
connect-src 'self' stops it exfiltrating the token. Verified
dynamically against every vector (attribute escape,
javascript:/data:/vbscript:, raw HTML, inline code, diff
blocks): inert everywhere, legitimate links preserved.

C-1 — API unauthenticated by default

An empty API_TOKEN left /chat (and the tool dispatch it implies)
open to anyone reaching the port, with the container bound to
0.0.0.0. The default is now inverted: the app refuses to start
without a token unless API_ALLOW_UNAUTHENTICATED=true records the
open posture in .env.local. require_token() itself is unchanged.

C-2 — Interpreters in the default shell allowlist

The default included python3, pip, find — each runs arbitrary
commands through its own arguments, yet only parts[0] is checked. New
default is interpreter-free; putting them back still works but is now a
written-down choice, with a warning logged at startup. Docstring
corrected: cwd=WORKSPACE_DIR confines nothing, the allowlist is the
only real boundary.

E-3 — git tool hardening

Removed stash (mutates the working tree despite the "read-only"
label), reject write/exec arguments (--output/--exec/…), and pass a
minimal env to the subprocess (it previously inherited the whole
environment, API_TOKEN included).

Example deployment (audit follow-up)

Root cause found while validating C-3: the out-of-repo deployment
compose referenced image: forge-core with no build: key, so
podman compose build answered "No services to build" and the
container kept serving frozen HTML through the entire patch cycle.

Adds deploy/compose.example.yaml: the full stack, versioned, with
build: context: . for the forge service — which only resolves
correctly when read from the repo root, the point of versioning it
here. Machine paths and secrets are placeholders; sysadmin bind mounts
are commented with a pointer to deploy/README.md.

Deferred (lots 2–3)

Non-root container, pinned dependencies, the read-only podman proxy
DoS, and — most importantly — the indirect prompt-injection hardening
(provenance delimiters in step_context + no tool escalation after
ingesting external data). The last one should land before v4.

escapeHtml() escaped only &, < and >, but its output lands in
attribute position -- inlineMarkdown built <a href="$2"> by direct
regex interpolation. `[x](" autofocus onfocus="location=name)`
therefore rendered as a valid <a> with an injected event handler and
executed with no interaction. A `javascript:` href needed no quote at
all.

This matters beyond the model's own output: the text passed to
formatContent() includes tool results -- web_fetch/research page
content, files:read, sysadmin logs -- so a hostile page reached it.
And the API token lives in localStorage, so the payoff was token
theft, then authenticated /chat calls, then whatever shell/files are
enabled.

Three layers, in order of how much each actually closes:
- escapeHtml now escapes " and ' too (kills the attribute escape)
- link rendering validates the scheme against an allowlist, and
  downgrades anything else to plain text (kills javascript:)
- GET / serves a CSP; 'unsafe-inline' is still required by the
  single-file UI, so this doesn't stop injected script from running
  -- but connect-src 'self' stops it shipping the token off-host

The two JS fixes are covered by source assertions rather than
execution: a regression tripwire, not a proof, and honest about it in
the test module docstring. The headers are tested for real.
API_TOKEN defaulted to empty, which left /chat, /run, /review,
/traces, /tools and the memory endpoints open to anyone reaching the
port -- and /chat dispatches whatever is in ENABLED_TOOLS. Combined
with the Containerfile's `--host 0.0.0.0`, "open by default" means
arbitrary tool execution by default.

config.py already carried a comment warning about this. A documented
unsafe default is still an unsafe default: it's the kind you notice
the day you add a published port to a compose file, not before.

So the default is inverted rather than re-documented. Starting with no
token now raises InsecureConfiguration from the app's lifespan, unless
API_ALLOW_UNAUTHENTICATED=true records in .env.local that the open
posture is intentional. The error names both ways out -- a refusal
that doesn't say how to proceed just gets worked around badly.

Nothing changes for the auth path itself: require_token() is
untouched, and the existing test suite (bare TestClient(app), which
doesn't run lifespan) still passes as-is.
The default was ls,cat,head,tail,wc,grep,find,python3,pip,pytest.
Three of those defeat the allowlist entirely, because only parts[0] is
ever checked:

    python3 -c "import os; os.system(...)"
    pip install <url>            (setup.py runs, plus network egress)
    find . -exec <anything> {} \;

An allowlist containing an interpreter is not an allowlist. The
default is now ls,cat,head,tail,wc,grep. Putting python3 back still
works and is a legitimate choice on a trusted local box -- it just has
to be written down in .env.local rather than being what you get by
default, and it now logs a warning at import naming what was switched
off.

Also corrects the module docstring, which claimed cwd=WORKSPACE_DIR
meant "relative paths cannot escape to the host filesystem". The cwd
confines nothing: `cat /etc/passwd` always worked. That sentence is
how an allowlist gets widened "safely", so it's replaced with an
explicit note that the allowlist is the only real boundary here --
and that HOME still points at the real home directory.
…dit E-3)

Three things, all in a tool whose docstring said "All operations are
read-only":

- `stash` was on the allowlist. Bare `git stash` is `git stash push`,
  which removes uncommitted changes from the working tree, and
  `stash drop`/`clear` destroy existing stashes. Not exfiltration, but
  on a project developed live it costs a session. Removed.

- Arguments were never inspected, only the subcommand. git has options
  that write files or run programs regardless of subcommand
  (--output, --exec, --upload-pack, --ext-diff), which turn a
  read-only subcommand into a write or an execution. Now refused by
  prefix, so both --output=x and --output x are caught.

- No env was passed to subprocess.run, unlike shell.py and test.py, so
  the git subprocess inherited the whole API process environment --
  API_TOKEN and OPENROUTER_API_KEY included. Now the same minimal env
  as its sibling tools, plus GIT_TERMINAL_PROMPT=0 so a repo asking
  for credentials fails instead of hanging until the timeout.

Not addressed here: _find_git_root() still walks up from cwd, so
inside the container this tool describes Forge's own /app repo rather
than the workspace. That's a behaviour change with real UX
consequences, so it's left for a separate decision rather than
smuggled into a security fix.
…follow-up)

The root cause behind an entire patch cycle serving stale UI: the
deployment compose lived outside the repo, referenced
`image: forge-core` with no `build:` key, so `podman compose build`
answered "No services to build" and never rebuilt the image. The
container kept serving frozen HTML while the source was patched.

This versioned example lives at deploy/compose.example.yaml with
`build: context: .` for the forge service, which only resolves
correctly when read from the repo root -- the whole point of
versioning it here rather than keeping a fragile relative context in
an out-of-repo compose. The other three services (forge-embedding,
forge-llm built separately; searxng upstream) stay image-only, and the
sysadmin bind mounts are commented with a pointer to deploy/README.md
so the example runs without the host proxies in place. Machine-specific
paths and secrets are placeholders.
@Kurtisone
Kurtisone merged commit 099feed into main Aug 12, 2026
2 checks passed
@Kurtisone
Kurtisone deleted the security-lot1 branch August 12, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant