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
24 changes: 24 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
# Refuse a commit message carrying a Claude session link.
#
# Commit messages are published. A session URL in one is a link into a private
# transcript, pasted under Eric's name, in a place that cannot be edited after
# it lands on main. It has happened more than once, because the agent harness
# supplies an attribution block that includes the link and a model following
# that instruction never sees this rule.
#
# So it is enforced here rather than remembered: the commit fails, the message
# is left for editing, and the fix is to delete the line.
#
# Install (once per clone): git config core.hooksPath .githooks

if grep -nE 'claude\.ai/(code/)?session|Claude-Session:' "$1" >/dev/null 2>&1; then
echo "commit-msg: this message contains a Claude session link." >&2
echo >&2
grep -nE 'claude\.ai/(code/)?session|Claude-Session:' "$1" >&2
echo >&2
echo "Commit messages are public and permanent. Remove the line and commit again." >&2
echo "Co-Authored-By is fine; the session URL is not." >&2
exit 1
fi
exit 0
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ jobs:
node "$f"
done

# The hook only protects a clone that has run `git config core.hooksPath
# .githooks`. This is the backstop for one that has not.
- name: No session links in commit messages
if: github.event_name == 'pull_request'
run: |
if git log --format='%H%n%B' \
"origin/${{ github.base_ref }}..${{ github.event.pull_request.head.sha }}" \
| grep -n 'claude\.ai'; then
echo "::error::a commit message on this branch carries a Claude session link"
exit 1
fi
echo "no session links"

- name: Verify CLI entry point
run: zimi --help

Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).

## [1.9.1] - 2026-09-06

Four fixes for what the first day of 1.9.0 turned up, one of them a security fix.

### Security

- **A reverse proxy on the same machine could hand out the first admin password (GHSA-5mw2-53vv-9pw6, again).** The bootstrap window waives the setup key for the machine running Zimi, and asked the resolved client address whether it was on that machine. Behind a reverse proxy on the same host, which is the standard NAS deployment, the forwarded address is correctly refused as a trusted-tier claim and the resolved address falls back to the proxy's own: loopback. Every remote client through that proxy read as being on the host and skipped the key. Being the host is now asked of the socket, and a forwarded request is never the host whatever the socket says. Anyone who ran 1.9.0 behind a same-host proxy with no admin password should set one.

### Added

- **`lan_admin`, for running with no password at all (#59).** The advisory closed a default that let an adjacent device race the owner for the first password. It also removed a way people genuinely run Zimi: one household, one LAN, no password. `lan_admin` (or `ZIMI_LAN_ADMIN=1`) says that the private network is a boundary you trust and restores the pre-1.9.0 behaviour. Off unless you turn it on, and it applies only while no password is set. It means a direct connection from that network: a request through a reverse proxy does not qualify, since Zimi cannot tell one client of a proxy from another.

### Fixed

- **The moon was upside down for half of every month (#60).** The sprite shades from a Sun vector already flipped for a waning moon, and the bright-limb angle carries that same flip, so every waning moon was turned a further 180 degrees: lit limb on the wrong side, maria inverted. The month's other half was right, which is why it read as random. The four places that draw a moon all agreed with each other and all agreed on the wrong number, so the test that compared them could not see it; there is now one that checks the answer against the sky instead.
- **`zimi import --setup` could set up a sidecar the server never looks at (#61).** It resolves its own data dir from the shell it runs in, so run from a terminal without the service's configuration it installs into a different library's state directory, reports success, and leaves the alive engine greyed out with nothing on screen to explain it. The Create page now names this server's directory in the command it gives you, so what you paste lands where the server looks.

## [1.9.0] - 2026-09-04

Zimi runs from a folder of ZIMs with no configuration, on a stick or a NAS or a fleet, and it makes ZIMs now.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Zimi

[![CI](https://github.com/epheterson/Zimi/actions/workflows/ci.yml/badge.svg)](https://github.com/epheterson/Zimi/actions/workflows/ci.yml)
[![Tests](https://img.shields.io/badge/tests-2652-brightgreen)](#)
[![Tests](https://img.shields.io/badge/tests-2655-brightgreen)](#)
[![Lighthouse Accessibility](https://img.shields.io/badge/Lighthouse%20a11y-100%2F100-success?logo=lighthouse&logoColor=white)](docs/plans/2026-04-26-accessibility.md)
[![WCAG 2.1 AA](https://img.shields.io/badge/WCAG%202.1-AA-blue)](docs/plans/2026-04-26-accessibility.md)
[![i18n](https://img.shields.io/badge/i18n-10%20languages-blueviolet)](#languages)
Expand Down
21 changes: 21 additions & 0 deletions docs/deployment-networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,27 @@ Every key is optional. The four path/bind keys have matching CLI flags; the rest
| `sso_aud` | `ZIMI_SSO_AUD` | string — the Access application's AUD tag |
| `sso_role` | `ZIMI_SSO_ROLE` | string — `user` (default), `limited` or `admin`, given to an account on first sign-in |
| `sso_proxy` | `ZIMI_SSO_PROXY` | list of CIDRs (a comma-separated string also works) — who may send the identity header; default any private peer |
| `lan_admin` | `ZIMI_LAN_ADMIN` | boolean — treat any private-network client as the admin on a **passwordless** instance; off by default, see [Running without a password](#running-without-a-password) |


### Running without a password

A passwordless Zimi is a real way to run it: one household, one LAN, nothing to type. Up to 1.8.2 that is what you got — any client on a private network was the admin.

That default had a hole ([GHSA-5mw2-53vv-9pw6](https://github.com/epheterson/Zimi/security/advisories)): "on a private network" includes every other device on the LAN, a Docker bridge, and anything on your tailnet, so an adjacent device could claim the first admin password before you did and lock you out of your own library. From 1.9.0 the bootstrap window is narrower: the machine running Zimi sets the first password with no secret, and any other device must present a one-time setup key the server prints on its first start.

If your threat model does not include the other devices on your own network, say so explicitly:

```yaml
# zimi.json
{ "lan_admin": true }
```

or `ZIMI_LAN_ADMIN=1`. Any private-network client is then the admin again, exactly as before 1.9.0, and no password is needed at all.

It is off unless you turn it on, and it applies only while no admin password is set. Once there is a password, that password governs. Turn it on when the LAN is a boundary you trust; leave it off on a shared, office, or campus network, where "private address" and "people you trust" are not the same set.

It also means a **direct** connection from your network. A request that arrived through a reverse proxy does not qualify, even one on the same machine, because Zimi cannot tell one client of that proxy from another: the forwarded address is not trustworthy, and the address it falls back to is the proxy's own. If you reach Zimi through a proxy, set an admin password rather than turning this on.

A setting from the file is applied by exporting it into its environment variable at startup, and only ever when the file is the layer that won — so an environment variable you exported yourself is never overwritten, and a setting you left out stays genuinely unset rather than being pinned to its default.

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "zimi"
version = "1.9.0"
version = "1.9.1"
description = "Offline knowledge server for ZIM files — search and read Wikipedia, Stack Overflow and 50+ sources with no internet"
readme = "README.md"
license = {text = "MIT"}
Expand Down
95 changes: 94 additions & 1 deletion tests/test_bootstrap_takeover.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,24 @@ def setUp(self):
threading.Thread(target=self._srv.serve_forever, daemon=True).start()
self._base = f"http://127.0.0.1:{self._srv.server_address[1]}"
self._real_client_ip = zhttp.ZimHandler._client_ip
self._real_socket_peer_ip = zhttp.ZimHandler._socket_peer_ip

def _as_peer(self, ip):
"""Make every request for the rest of this test appear to come from
``ip`` — a real non-loopback peer, which a forwarded header cannot
fake past the anti-spoof rule."""
fake past the anti-spoof rule.

The SOCKET moves too, not just the resolved client IP. Stubbing
_client_ip alone left the connection genuinely on loopback, so these
tests were modelling a remote attacker who, to the code that decides
who counts as the host, was sitting at the machine. That is the gap
the same-host reverse proxy fell through."""
zhttp.ZimHandler._client_ip = lambda _self, _ip=ip: _ip
zhttp.ZimHandler._socket_peer_ip = lambda _self, _ip=ip: _ip

def tearDown(self):
zhttp.ZimHandler._client_ip = self._real_client_ip
zhttp.ZimHandler._socket_peer_ip = self._real_socket_peer_ip
self._srv.shutdown()
manage._env_pw_hash_cache = None
import shutil
Expand Down Expand Up @@ -130,6 +139,90 @@ def test_the_host_itself_bootstraps_freely(self):
self.assertEqual(status, 200, body)
self.assertTrue(manage._get_manage_password_hash())

def test_a_same_host_reverse_proxy_does_not_make_everyone_the_host(self):
"""The advisory's fix, reopened by the commonest deployment there is.

These tests replace _client_ip wholesale, so until this one nothing
ever executed the function that decides who counts as the host. In
production a reverse proxy on the SAME machine — Synology, nginx in
front of 8899, the usual NAS shape — connects from 127.0.0.1 and puts
the real client in X-Forwarded-For. _client_ip refuses to let that
header claim a trusted-tier address, so it falls back to the direct
peer, which is loopback: every remote client behind that proxy became
the host and skipped the setup key.

So this test does NOT stub the peer. The socket really is loopback,
exactly as it is in that deployment, and the forwarded header is the
only thing distinguishing it from the owner sitting at the machine.
"""
for header in ("X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"):
with self.subTest(header=header):
status, body = self._post(
"/manage/set-password",
{"password": "attacker-owns-it"},
headers={header: "192.168.1.50"},
)
self.assertEqual(status, 403, body)
self.assertFalse(
manage._get_manage_password_hash(),
f"a client forwarded by {header} claimed the first password",
)

def test_the_lan_can_be_trusted_but_only_on_purpose(self):
"""Issue #59: 1.9.0 removed a way people actually run Zimi.

Before the advisory, a passwordless instance treated any private
client as admin, and plenty of single-household servers depended on
that: no password, LAN only, done. The fix was right and the
replacement was missing, so those users found Settings simply shut.

The opt-in has to be typed by whoever runs the server, and with it off
— the default, and what every other test here exercises — the LAN is
still refused."""
self._as_peer(ADJACENT)
status, _ = self._post("/manage/set-password", {"password": "nope"})
self.assertEqual(status, 403, "the default must still refuse the LAN")

os.environ["ZIMI_LAN_ADMIN"] = "1"
try:
status, body = self._get("/manage/stats")
self.assertEqual(status, 200, body)
finally:
os.environ.pop("ZIMI_LAN_ADMIN", None)

status, _ = self._get("/manage/stats")
self.assertEqual(status, 403, "switching it back off must shut the door")

def test_lan_admin_does_not_hand_the_internet_the_keys(self):
"""The escalation `lan_admin` would otherwise carry.

Behind a reverse proxy on the same host, _client_ip cannot identify
the caller: it refuses the forwarded address as a trusted-tier claim
and falls back to the hop, which is loopback. Every client of that
proxy therefore resolves as "private" — including one on the far side
of the internet. Left at `_is_private_client`, turning on lan_admin
would have made all of them the admin of a passwordless server.
"""
os.environ["ZIMI_LAN_ADMIN"] = "1"
try:
status, body = self._post(
"/manage/set-password",
{"password": "attacker-owns-it"},
headers={"X-Forwarded-For": "8.8.8.8"},
)
self.assertEqual(status, 403, body)
self.assertFalse(
manage._get_manage_password_hash(),
"lan_admin let a forwarded client claim the first password",
)
# And a genuinely direct private peer still gets in, which is the
# entire point of the setting.
self._as_peer(ADJACENT)
status, body = self._get("/manage/stats")
self.assertEqual(status, 200, body)
finally:
os.environ.pop("ZIMI_LAN_ADMIN", None)

def test_a_remote_client_with_the_key_bootstraps_and_spends_it(self):
key = manage.ensure_setup_key()
self.assertTrue(key)
Expand Down
63 changes: 63 additions & 0 deletions tests/test_ci_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import re
import shutil
import subprocess
import tempfile

import pytest

Expand Down Expand Up @@ -191,3 +192,65 @@ def test_the_pr_gate_runs_on_every_runner_the_release_builds_on():
f"the release: {sorted(release - gate) or 'none'}. A platform in the "
"release alone is one whose failures cannot be seen before the tag."
)


def test_the_repo_refuses_commit_messages_with_session_links():
"""A session URL in a commit message is a link into a private transcript,
published under the repo owner's name, in the one place that cannot be
edited after it lands on main.

It is enforced by a hook rather than remembered because the agent harness
supplies an attribution block containing that link, and anything following
that instruction never sees the rule. This test is what keeps the hook
itself from being deleted or quietly stopping working."""
hook = ROOT / ".githooks" / "commit-msg"
assert hook.is_file(), "the commit-msg hook is gone"
if os.name != "nt":
assert os.access(hook, os.X_OK), "the commit-msg hook is not executable"

# Run it THROUGH sh rather than as a program. It is a `#!/bin/sh` script,
# and Windows does not read shebangs: executing it directly raises
# "[WinError 193] %1 is not a valid Win32 application". Git for Windows
# ships the sh that git itself uses to run hooks, so this is also how the
# hook actually runs on that platform.
shell = shutil.which("sh") or shutil.which("bash")
if not shell:
pytest.skip("no POSIX shell here to run the hook with")

def run(message):
with tempfile.NamedTemporaryFile("w", suffix=".msg", delete=False) as fh:
fh.write(message)
path = fh.name
try:
return subprocess.run(
[shell, str(hook), path], capture_output=True, text=True
)
finally:
os.unlink(path)

bad = run("a change\n\nClaude-Session: https://claude.ai/code/session_x1\n")
assert bad.returncode != 0, "the hook let a session link through"

plain_url = run("a change\n\nsee https://claude.ai/code/session_x1 for context\n")
assert plain_url.returncode != 0, "the hook only catches the trailer form"

good = run(
"a change\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n"
)
assert good.returncode == 0, f"the hook rejected a clean message: {good.stderr}"


def test_no_commit_on_this_branch_carries_a_session_link():
"""The hook stops new ones; this catches any that predate it, while the
branch can still be rewritten."""
done = subprocess.run(
["git", "log", "origin/main..HEAD", "--format=%H%n%B"],
capture_output=True, text=True, cwd=ROOT,
)
if done.returncode != 0:
pytest.skip("no origin/main to compare against here")
offenders = [ln for ln in done.stdout.splitlines() if "claude.ai" in ln]
assert not offenders, (
"commit message(s) on this branch carry a session link:\n "
+ "\n ".join(offenders)
)
13 changes: 10 additions & 3 deletions tests/test_create_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,19 @@
)


def _wait(predicate, tries=600, why="condition never came true"):
for _ in range(tries):
# A deadline, not a poll count — same reason as _wait_done in
# test_create_routes.py: a fixed number of turns measures how fast the runner
# is, not how long the job took, so a loaded machine fails a passing test.
_WAIT_SECONDS = 30


def _wait(predicate, timeout=_WAIT_SECONDS, why="condition never came true"):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
raise AssertionError(why)
raise AssertionError(f"{why} (waited {timeout}s)")


@pytest.fixture
Expand Down
20 changes: 17 additions & 3 deletions tests/test_create_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,27 @@ def _get(path, private=True, params=None):
return h


def _wait_done(tries=400):
for _ in range(tries):
# A deadline, not a poll count. 400 polls at 10ms looks like four seconds and
# is not: each turn of the loop also makes an HTTP request, so the real ceiling
# is however fast the runner happens to be. On a contended macOS runner the
# 550-line buffer test ran out of turns and failed as "job never finished" —
# the same commit passed on the next run, which is the signature of a limit
# that measures the machine rather than the job.
#
# Time-based instead, and generous: a passing test still returns the moment the
# job is done, so the only thing that waits 30 seconds is a job that genuinely
# hung, which is worth 30 seconds to report honestly.
_WAIT_DONE_SECONDS = 30


def _wait_done(timeout=_WAIT_DONE_SECONDS):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = _get("/manage/create/status").body
if body.get("done") or not body.get("active"):
return body
time.sleep(0.01)
raise AssertionError("creation job never finished")
raise AssertionError(f"creation job never finished within {timeout}s")


@pytest.fixture(autouse=True)
Expand Down
Loading
Loading