Synapse v2.1.15 — "a prefix is not a name"¶
Patch release closing two defects that delete data and one that leaks a credential, found by reading the backup and page-history retention paths during weekly maintenance. No schema migrations, no new endpoints, no API changes, no new configuration keys.
Like 2.1.8 through 2.1.14, none of the three came from a live failure report. Two of them are literally the same mistake in two different modules, which is what makes this week worth writing down:
A glob of the form
<name>-*does not select "the files of<name>". The*also swallows the<suffix>-of every longer name that begins with<name>-.
Both places then deleted what they had over-matched. In one, a vault's retention pruned a sibling vault's database dumps and kept none of its own; in the other, saving one wiki page erased a differently-named page's revision history. The third defect is the familiar "code that states an intent the code does not keep": a module whose header promises the DSN is never logged, and one error path that logs it.
Fixed¶
One vault's backup retention deleted another vault's dumps — and kept none of its own¶
run_backup() writes synapse-<vault_id>-<UTC stamp>.dump and then calls _apply_retention()
to keep the newest N. That function decided what belonged to a vault with a glob:
pattern = f"{_ARCHIVE_GLOB_PREFIX}{vault_id}-*{_ARCHIVE_SUFFIX}"
archives = sorted(backup_dir.glob(pattern), key=lambda p: p.name, reverse=True)
stale = archives[keep:]
For vault home that is synapse-home-*.dump — which also matches every archive belonging to
a vault called home-lab, because * happily absorbs lab-20260101T000000Z. Vault ids that
are --separated extensions of each other are not exotic; home / home-lab and work /
work-notes are the names people actually choose.
Over-matching alone would merely be wasteful. What makes it destructive is the line
immediately after, which sorts by name to decide which archives are newest. After the shared
synapse-home- prefix, the next character is l for the sibling and a digit for the real
archives — and l (0x6C) sorts above 2 (0x32). So with reverse=True all of home-lab's
archives sort first and are kept as "the newest", while every one of home's own dumps falls
past the keep window and is unlinked.
Reproduced before the fix, three archives each and keep=3:
before: synapse-home-2026010{1,2,3}…, synapse-home-lab-2026010{1,2,3}…
deleted: 3
after : synapse-home-lab-2026010{1,2,3}… ← vault "home" has zero backups
Vault home asked to keep three and was left with none, while its retention counter reported
a healthy prune. This is the disaster-recovery artifact — the thing run_backup exists to
produce before Watchtower recreates the containers — so the failure mode is discovering there
is no backup at the moment one is needed.
Archives are now enumerated with a vault-agnostic glob and filtered through an anchored matcher that also pins the fixed-width timestamp:
def _archive_name_re(vault_id: str) -> re.Pattern[str]:
return re.compile(
rf"^{re.escape(_ARCHIVE_GLOB_PREFIX)}{re.escape(vault_id)}-"
rf"{_ARCHIVE_STAMP_PATTERN}{re.escape(_ARCHIVE_SUFFIX)}$"
)
Three things follow. Only this vault's archives can be pruned. The name sort stays
chronological, because every surviving name shares an identical prefix and differs only in the
fixed-width stamp. And foreign filenames — a half-written …dump.tmp, a hand-renamed archive
with no timestamp — are no longer candidates for deletion at all. Escaping the vault id also
closes the latent case of an id containing glob metacharacters.
Overwriting one wiki page deleted another page's history¶
write_block_page() keeps a bounded on-disk history of each page's previous bytes under
.synapse/page-history/<stem>-<n>.md, capped by ingest_page_history_max_per_page. The
listing had the same prefix-glob flaw, compounded by an unanchored index regex:
_BACKUP_INDEX_RE = re.compile(r"-(\d+)\.md$")
...
for path in history_dir.glob(f"{stem}-*.md"):
match = _BACKUP_INDEX_RE.search(path.name)
Neither half is an identity test. _sanitize_backup_stem() deliberately keeps - (it is
inside the allowed character class [A-Za-z0-9._-]), so concepts/rag.md and
concepts/rag-pipeline.md become wiki__concepts__rag and wiki__concepts__rag-pipeline —
one a --separated prefix of the other. The glob for rag returns
wiki__concepts__rag-pipeline-7.md, and an unanchored search reads 7 straight out of it.
The listing feeds two callers, and the harmful one is the trim that runs after every overwrite:
while len(backups) > cap:
_idx, oldest = backups.pop(0)
oldest.unlink(missing_ok=True)
It deletes the lowest index first. Because the list held another page's files, churning the
shorter-named page deleted the longer-named page's history. The regression test drives this
through the real write_block_page() path, and without the fix the sibling's history comes
back as [] — both of its revisions gone, while the page that caused it kept all of its own.
The other caller, next_idx = existing[-1][0] + 1, is the benign half: it takes the maximum,
so it skips index numbers rather than overwriting.
The regex is now the authoritative gate, anchored on the exact escaped stem:
def _backup_index_re(stem: str) -> re.Pattern[str]:
return re.compile(rf"^{re.escape(stem)}-(\d+)\.md$")
The glob stays narrow, so the OS still does the coarse filtering and the directory is not re-scanned in full; every name the regex accepts is a subset of what the glob returns.
Security¶
The Postgres password reached the backend log¶
app/ops/backup.py opens with an explicit promise:
Security: the Postgres password is passed via the
PGPASSWORDsubprocess env var (never on the command line, so it never appears inps/logs); the DSN itself is never logged.
The command line honours that. The parse-failure path did not:
raise _DsnParseError(f"database_url is missing host or database name: {database_url!r}")
run_backup() catches that error, logs it at ERROR, and returns the same string in
BackupSummary.error_message — which trigger_system_update() logs a second time when a
pre-update backup fails. So a password configured in DATABASE_URL lands in plaintext in the
application log, twice, in the one place the module had declared off-limits.
The root cause is a guard written to be helpful — here is the DSN I could not parse — about a value the module had classified as a secret everywhere else.
It needs no adversary. _DsnParseError fires when the DSN has no host or no database name, and
a unix-socket DSN has no host by construction:
postgresql+asyncpg://synapse:SuperSecret123@/synapse
That is ordinary Postgres configuration, and the resulting log line is exactly what an operator would paste into a bug report or a support thread.
The message is now redacted, and the netloc is rebuilt from the parsed components rather
than string-replaced, so a password containing @ or : cannot survive into the output:
database_url is missing host or database name: 'postgresql+asyncpg://synapse:***@/synapse'
A DSN with no password is returned unchanged — there is nothing in it to hide — and the message stays actionable: the operator can still see which DSN was rejected and why.
Changed¶
mypy strict restored after a SQLAlchemy minor landed underneath CI¶
mypy app began failing on main with two errors, in two files nobody had touched:
app/ingest/context.py:94: Argument 1 to "append" of "list" has incompatible type "str | None"
app/ops/lint/detectors.py:102: Need type annotation for "target_rows"
Nothing in the repository changed. CI was green on main on 2026-09-18 (the 2.1.14 merge),
with mypy pinned to ==2.1.0 — still the installed version. What changed is underneath:
backend/pyproject.toml asks for sqlalchemy[asyncio]>=2.0.0 with no upper bound, and CI
installs with a bare pip install -e ., so the job now resolves SQLAlchemy 2.1, which
narrowed the element typing of Row/Result. Both sites were always loosely typed; 2.0 simply
inferred Any and said nothing.
Both fixes are type-only, with no runtime behaviour change. Page.title is
Mapped[str | None] and the query already excludes NULLs with Page.title.is_not(None), which
the checker cannot see, so the guard is now explicit and is a no-op at runtime;
func.distinct() erases the column type, so target_rows carries the annotation the line
below it already assumes.
This is included in a patch release because the typecheck stage blocks merge: left alone it
makes every PR red for a cause outside the repository — which is also how a team learns to
re-run a red job without reading it. The underlying problem is a process one, and is filed in
docs/reference/ROADMAP-3.0-IDEAS.md rather than attempted here.
Also in this release¶
One new entry in the 3.0 backlog (docs/reference/ROADMAP-3.0-IDEAS.md): backend
dependencies are unpinned, so a minor upstream can break CI without a commit — the mechanism
behind the mypy failure above. The two lines are fixed; the mechanism that let them appear on a
branch nobody touched is not, because closing it means a lockfile or constraints file plus a
decision about who regenerates it and how a deliberate bump is told apart from drift. That
decision interacts with the long-standing pypdf entry (41 distinct advisories, all fixed only
in the 6.x line, behind a <6 pin) and should be taken together with it, not separately.
Upgrade notes¶
docker compose pull && docker compose up -d
- No schema migration. No SQLAlchemy model changed, so
docs/er/schema.mmdis unchanged and no Alembic revision ships with this release. - No API change.
docs/api/openapi.jsondiffers only in the version string. - No new configuration.
DATABASE_URL,BACKUP_ROOT,BACKUP_RETENTION_COUNTandINGEST_PAGE_HISTORY_MAX_PER_PAGEkeep their meanings and defaults; nothing new to set in.env. - Check your backup directory if you run more than one vault. The retention fix stops
further damage but cannot restore what earlier runs deleted. If two of your vault ids are
--separated extensions of each other (home/home-lab), list$BACKUP_ROOT/synapse-*.dumpand confirm the shorter-named vault still has the number of archives you expect. If it does not, take a fresh dump —POST /ops/schedules/backup/run-now— before relying on the next scheduled one. - Page history may look shorter than the cap on some pages. Same reason: revisions deleted
by an earlier run are gone. New revisions accumulate correctly from this release on, per
page, up to
ingest_page_history_max_per_page. No page content was ever at risk — the live files inwiki/are untouched by this path, only the.synapse/page-history/copies. - If you have ever seen a
database_url is missing host or database nameline in your logs, treat the password in it as disclosed and rotate it. The line is redacted from this release forward, but existing log files, shipped logs anddocker logsbuffers still hold the original. - No client-side state to clear. Existing OAuth grants, refresh tokens, API tokens, conversations and vault content are untouched.