Skip to content

fix: an archive and an export are private from the moment they exist - #903

Merged
blaipr merged 1 commit into
mainfrom
fix/an-export-is-private-from-the-moment-it-exists
Sep 3, 2026
Merged

fix: an archive and an export are private from the moment they exist#903
blaipr merged 1 commit into
mainfrom
fix/an-export-is-private-from-the-moment-it-exists

Conversation

@blaipr

@blaipr blaipr commented Sep 3, 2026

Copy link
Copy Markdown
Member

The backup archives and the XML export are restricted to their owner — but only once they are
written. ArchiveHandler and XmlExport both chmod 0600 after the write completes, and
buildFromDirectory() walks the whole application tree first, so on an installation of any size the
finished file sits at the process umask — measured 0644, which on a shared host is every local user
— for as long as building it took. A run that dies in between leaves it that way for good.

What is in those files: the database dump, with every account's encrypted secret and the
master-password hash; config.xml, with the database credentials and the crypto keys; and for the
export, when no export password was given, the name, login, URL and notes of every account in the
clear.

FileBackupHandlersFactory already shows the right shape — it opens database.sql and restricts it
before any write, with a comment saying why. These are the same window, left open.

Why the umask, and not a chmod before the write

PharData gives no way to restrict an archive beforehand, and I checked both halves of that rather
than assuming:

  • it does not create the file when it is constructed, so there is nothing there to chmod;
  • compress() refuses outright when its target already exists — "phar … exists and must be
    unlinked prior to conversion"
    — so the .gz cannot be pre-created either.

Narrowing the umask for the duration covers the tar, the gz and the export alike, with no window at
all. The explicit chmods stay as the guarantee: the umask decides what a newly created file gets,
and nothing here should depend on that alone.

The umask is restored in a finally. Leaking 0177 into the rest of the request would make every
later file owner-only too — including the cache and the compiled container — and that is the kind of
change that only shows up much later.

Tests

ArchiveHandlerTest, new. The mutation result is the interesting part, because it separates the two
mechanisms:

result
remove the post-hoc chmod, keep the umask passes — the umask alone makes the archive private
remove the umask as well fails

So the umask is demonstrably doing the work, rather than the chmod quietly covering for it. The
other tests pin the umask being restored — including when the archive cannot be built, which is what
the finally is for — that the uncompressed tar does not survive, and that Phar honours the umask
at all, since the whole fix rests on that and a future PHP could stop.

Two things noticed and deliberately left

  • compressDirectory() returns phar:///…/archive.tar.gz/<first entry> — a URL for a file inside
    the archive, not the archive's own path. Every caller discards it, so it is a wart, not a defect.
  • Its ?string $regex = null default reaches PharData::buildFromDirectory(), which requires a
    string, so passing no regex is a TypeError. The one production caller always passes one, so it
    is unreachable today. Both belong in their own change.

ArchiveHandler and XmlExport both chmod 0600 after the write completes, and
buildFromDirectory() walks the whole application tree first — so on an installation of any
size the finished file sits at the process umask, measured 0644, for as long as building
it took, and a run that dies in between leaves it that way for good. Those files hold the
database dump with every account's encrypted secret and the master-password hash,
config.xml with the credentials and crypto keys, and for the export with no password the
name, login, URL and notes of every account in the clear.

FileBackupHandlersFactory already shows the right shape, restricting database.sql before
any write. PharData offers no equivalent: it does not create the archive when constructed,
so there is nothing to chmod beforehand, and compress() refuses when its target already
exists. Narrowing the umask for the duration covers the tar, the gz and the export alike,
with no window at all, and it is restored in a finally — leaking 0177 into the rest of the
request would quietly make every later file owner-only.

The chmods stay as the guarantee. The mutation result separates the two: with the chmod
removed the archive is still private, and only removing the umask as well fails it.
@blaipr
blaipr merged commit 4f64636 into main Sep 3, 2026
8 checks passed
@blaipr
blaipr deleted the fix/an-export-is-private-from-the-moment-it-exists branch September 3, 2026 00:19
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