Skip to content

fix: harden the loopback surfaces a session exposes - #1067

Merged
omartelo merged 1 commit into
mainfrom
fix/loopback-hardening
Nov 8, 2026
Merged

omartelo merged 1 commit into
mainfrom
fix/loopback-hardening

Conversation

@omartelo

@omartelo omartelo commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator

What

Three fixes on the local boundaries SECURITY.md names.

Preview server accepted any Host (DNS rebinding). internal/preview serves a session's checkout on a predictable port (25000-25999) and answered any Host, so a page in the user's ordinary browser could rebind a name it controls to 127.0.0.1 and read the checkout, .env included. The server now answers only 127.0.0.1:<port> and localhost:<port> (403 otherwise). The only caller is the session browser lich drives, which loads the URL preview.URL builds with 127.0.0.1:<port>.

Token on a command line. restartChain in internal/appupdate pasted curl ... "/restart?token=$LICH_TOKEN", which expands the token into curl's argv, readable by every user on the machine through /proc/<pid>/cmdline. authorized in internal/terminal/transport.go now also accepts Authorization: Bearer <token> (?token= keeps working for the lich-plugin hooks and the in-process Go clients). The chain, install.sh and native/slice.sh feed the header to curl as a config line on stdin: printf is a shell builtin, so the expanded token never enters an argv (-H would leak the same way). The PowerShell chain is in-process and unchanged. SECURITY.md now promises "never in a log, a crash report or a process's command line" instead of "a URL", since in-process clients legitimately use ?token=. The appupdate tests that pin the pasted command change because the contract (the pasted command) changed.

Config dir world-readable. ~/.config/lich was created 0755 and left as is on an existing install, with lich.db (prompts and session text) inside. store.open now creates it 0700 and chmods it 0700 on every start, so existing installs are tightened too; the parent (~/.config) is never touched, and a chmod failure is logged, not fatal.

Docs: docs/ceilings.md (preview Host rule), docs/hooks/README.md and docs/cli.md (Bearer beside ?token=), CHANGELOG.md under ### Security.

Test plan

  • TestServedCheckoutRefusesAForeignHost: served 200 to Host: evil.example before the fix, 403 after; 127.0.0.1:<port>, localhost:<port> and LOCALHOST:<port> still 200; bare 127.0.0.1, a wrong port and 127.0.0.1.evil.test refused.
  • TestRestartAcceptsABearerToken: 401 before, 204 after; a wrong Bearer stays 401. Existing ?token= tests unchanged and green.
  • TestOpenMakesTheDatabaseDirectoryPrivate: a pre-existing 0755 lich/ becomes 0700 on open, its parent stays 0755.
  • The new chain run in bash, zsh, sh (bash as sh) and fish against a loopback test server, with a wrapper on PATH recording curl's argv: all four delivered Authorization: Bearer <token>, and the token appeared in none of the argv lines.
  • Local gate green, each step in the memory jail: gofmt -l . empty, go vet ./..., go test ./... on the backend; gofmt, go vet, go test under native/.

Three small fixes on the local boundaries SECURITY.md names.

The preview server (internal/preview) served any Host on a predictable
port, so a page in the user's ordinary browser could rebind a name it
controls to 127.0.0.1 and read the checkout, .env included. It now
answers only 127.0.0.1:<port> and localhost:<port>.

The restart chain lich pastes after a self-update expanded $LICH_TOKEN
into curl's argv, readable by every user through /proc/<pid>/cmdline.
The transport accepts Authorization: Bearer <token> beside ?token=, and
the chain, install.sh and native/slice.sh feed the header through curl's
stdin config instead. The appupdate tests change because the contract
(the pasted command) changed.

lich's config directory is made 0700 on every start, existing installs
included; the parent is left alone and a chmod failure is logged.
@omartelo
omartelo merged commit 594efc0 into main Nov 8, 2026
8 checks passed
@omartelo
omartelo deleted the fix/loopback-hardening branch November 8, 2026 23:55
omartelo added a commit to lichdotdev/lich-plugin that referenced this pull request Nov 8, 2026
Every hook script posted to "http://127.0.0.1:$LICH_PORT/<endpoint>?token=$LICH_TOKEN",
so the expanded token was an argument of curl, readable by every user on the
machine through /proc/<pid>/cmdline each time a hook fired.

The scripts now send it as an Authorization: Bearer header fed to curl as a
config line on stdin (printf is a shell builtin, so the token enters no argv;
a -H argument would leak the same way). This is the shape lich adopted in
lichdotdev/lich#1067, which makes the listener accept the header.

A lich released before that answers 401 to a report whose token is only in
the header. The scripts read the status of the first request and, on a 401
alone, send the report again with ?token=, so a card keeps its state under any
lich. A 500 or a refused connection is not retried.
omartelo added a commit that referenced this pull request Nov 9, 2026
…ontract (#1069)

#1067 made the loopback listener accept Authorization: Bearer beside
?token=, a contract change lich-plugin ships as 0.21.0 (its hooks send the
header). The ceiling stayed at 0.21.0, so no lich would install that
release. The compat tests move with the contract: 0.21.0 is now in range
and 0.22.0 is the first release past it.
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