Skip to content

Verify the server jar before running it, and stop caching it in a shared directory - #502

Merged
chrismattmann merged 2 commits into
masterfrom
verify-the-server-jar-before-running-it
Nov 9, 2026
Merged

chrismattmann merged 2 commits into
masterfrom
verify-the-server-jar-before-running-it

Conversation

@chrismattmann

Copy link
Copy Markdown
Owner

The jar this package downloads gets executed, and until now anything that could write to the temp directory could decide what that jar was.

The hole

The cache lived at a predictable path — /tmp/tika-server.jar on Linux — and was verified against a checksum file at an equally predictable path beside it. checkJarSig fetched the real checksum only when that local file did not exist:

localChecksumPath = '.'.join([jarPath, TikaJarHashAlgo])
if not os.path.isfile(localChecksumPath):
    getRemoteJar('.'.join([tikaServerJar, TikaJarHashAlgo]), localChecksumPath)

So a local user who created both /tmp/tika-server.jar and /tmp/tika-server.jar.md5 before another user's first parse had their jar checked against their own checksum, found authentic, and executed as that user. The sticky bit on /tmp does not prevent it — the attacker never overwrites anyone's file, they create it first, which in practice means first after a boot or a /tmp cleanup.

The hash algorithm was never the problem. TIKA_JAR_HASH_ALGO=sha512 left the bypass completely intact, because the digest was still read from a file the same attacker could write. That variable is now ignored and logs why.

Four more defects in the same path

  1. The verification failure branch ran the jar anyway. On a mismatch the old code deleted the jar, redownloaded, and started the second copy without checking it. It also never removed the stale checksum — so a planted checksum alone, no jar required, made every call redownload 76 MB forever.
  2. The default download was plaintext http://, from search.maven.org rather than the canonical repository. Jar and checksum both travelled in the clear, so substituting them needed no local account at all — a network attacker was enough. Now https://repo1.maven.org/maven2/.
  3. startServer opened /tmp/tika-server.log with open(path, 'w'), which truncates: a symlink planted there blanked any file the user running tika could write. The client log used logging.FileHandler on /tmp/tika.log, an append primitive by the same route.
  4. getRemoteFile wrote URL-fetched documents into the shared directory under predictable, pre-creatable names.

What replaces it

  • The expected SHA-512 ships in the package, keyed by version, so verifying the jar depends on neither the network nor anything on local disk. The 3.3.2 entry is the artifact signed by ASF release key 184454FAD8697760F3E00D2E4A51A45B944FFD51, checked against https://downloads.apache.org/tika/KEYS.
  • A digest is never written to disk. For a version with no pinned entry, or a custom TIKA_SERVER_JAR, the published digest is fetched over https into memory only, strongest first (sha512, sha256, sha1), and refused outright over plaintext where it would prove nothing. TIKA_SERVER_JAR_SHA512 overrides for a private build.
  • Verification failure raises instead of starting anything.
  • Downloads land on a pid-unique temporary name, are verified, and only then os.replace'd into the path that gets executed — so a wrong or half-written jar is never at the name that runs.
  • The cache defaults to a per-user directory created 0700 (~/.cache/tika, ~/Library/Caches/tika, %LOCALAPPDATA%\tika). TIKA_PATH, TIKA_LOG_PATH and TIKA_FILES_PATH still override.
  • Every cache file is opened with O_NOFOLLOW and refused if owned by another uid or writable by other users — so pointing TIKA_PATH back at a shared directory is still safe.

Scope

On macOS tempfile.gettempdir() is already a per-user 0700 directory under /var/folders, so macOS was largely not exposed. On Linux it is /tmp, mode 1777. This was a Linux and container problem.

An existing /tmp/tika-server.jar is reported once as unused rather than deleted — on a shared host it may not be ours to delete, and the sticky bit would refuse.

Verification

Confirmed against the old code rather than assumed. Same planted jar and matching checksum, no network in either run:

3.3.2 (old)  checkJarSig -> True     network used: none
patched      checkJarSig -> False    network used: none

and the patched failure is specific:

ERROR  .../tika-server.jar does not match the sha512 pinned in tika-python:
       expected fb1f2fe5...c2c9e9, got 75be805c...acb433

End to end on the real artifact: 76,497,574 bytes fetched over https from repo1, digest matched the pinned value, server started, PDF parsed (status 200, 11,297 characters), and the cache afterwards contained no checksum file — only tika-server.jar, tika-server.log, tika.log.

tests/test_jar_verification.py adds 20 cases; the suite goes 28 → 48, all passing. They cover the planted pair, a sidecar holding the correct pinned digest (must still not launder a forgery), refusal-to-start, cleanup after a rejected download, symlinked jar and log paths, a file owned by another uid, a world-writable jar, and that the default URL is https and the default cache is not the shared temp directory.

Unrelated, noticed in passing

uv run --group tests currently cannot resolve on Python 3.10/3.11 — the docs group wants sphinx>=9.1 which needs 3.12+, while requires-python is >=3.10. Not touched here; I ran the suite in a standalone env. Worth its own change.

getRemoteFile's outDir, where a caller names its own output path, is deliberately unchanged — it is not a cache and not attacker-predictable.

…red directory

The jar this package downloads gets executed, and until now anything that
could write to the temp directory could decide what that jar was.

The cache lived at a predictable path -- /tmp/tika-server.jar on Linux -- and
was verified against a checksum file at an equally predictable path next to
it. checkJarSig fetched the real checksum only when that local file did not
exist:

    localChecksumPath = '.'.join([jarPath, TikaJarHashAlgo])
    if not os.path.isfile(localChecksumPath):
        getRemoteJar('.'.join([tikaServerJar, TikaJarHashAlgo]), localChecksumPath)

So a local user who created both /tmp/tika-server.jar and
/tmp/tika-server.jar.md5 before another user's first parse had their jar
checked against their own checksum, found authentic, and executed as that
user. The sticky bit on /tmp does not prevent this: the attacker never
overwrites anybody's file, they create it first, which in practice means
first after a boot or a /tmp cleanup.

The hash algorithm was never the problem. Setting TIKA_JAR_HASH_ALGO=sha512
left the bypass completely intact, because the digest was still read from a
file the same attacker could write. TIKA_JAR_HASH_ALGO is now ignored and
says so.

Four more defects in the same path, all fixed here:

1. The verification failure branch ran the jar anyway. On a mismatch the old
   code deleted the jar, downloaded it again, and started the second copy
   without checking it. It also never removed the stale checksum, so a planted
   checksum alone -- no jar needed -- made every call redownload 76MB forever.
   A check whose failure branch runs the file is not a check.

2. The default download was plaintext http, from search.maven.org rather than
   the canonical repository. The jar and the checksum beside it both travelled
   in the clear, so substituting them needed no local account at all; a
   network attacker was enough. Now https://repo1.maven.org/maven2/.

3. startServer opened /tmp/tika-server.log with open(path, 'w'), which
   truncates. A symlink planted there blanked any file the user running tika
   could write. The client log used logging.FileHandler on /tmp/tika.log, an
   append primitive by the same route.

4. getRemoteFile wrote documents fetched from URLs into the shared directory
   under predictable, pre-creatable names.

What replaces it:

- The expected SHA-512 ships in the package, keyed by version, so verifying
  the jar depends on neither the network nor anything on local disk. The 3.3.2
  entry is the artifact signed by ASF release key 184454FAD8697760F3E00D2E4A51A45B944FFD51,
  checked against https://downloads.apache.org/tika/KEYS.
- A digest is never written to disk. For a version with no pinned entry, or a
  custom TIKA_SERVER_JAR, the published digest is fetched over https into
  memory only, strongest first (sha512, sha256, sha1), and refused outright
  over plaintext where it would prove nothing. TIKA_SERVER_JAR_SHA512
  overrides for a private build.
- Verification failure raises instead of starting anything.
- Downloads land on a pid-unique temporary name, are verified, and only then
  os.replace'd into the path that gets executed, so a wrong or half-written
  jar is never at the name that runs.
- The cache defaults to a per-user directory created 0700 (~/.cache/tika,
  ~/Library/Caches/tika, %LOCALAPPDATA%\tika) instead of the shared temp
  directory. TIKA_PATH, TIKA_LOG_PATH and TIKA_FILES_PATH still override.
- Every cache file is opened with O_NOFOLLOW and refused if it is owned by
  another uid or writable by other users, so pointing TIKA_PATH back at a
  shared directory is still safe.

Scope worth recording: on macOS tempfile.gettempdir() is already a per-user
0700 directory under /var/folders, so macOS was largely not exposed. On Linux
it is /tmp, mode 1777. This was a Linux and container problem.

An old /tmp/tika-server.jar is reported once as unused rather than deleted; on
a shared host it may not be ours to delete, and the sticky bit would refuse.

Tests: tests/test_jar_verification.py, 20 cases. The suite goes 28 -> 48.
Confirmed against the old code rather than assumed -- the same planted jar and
matching checksum, no network in either run:

    3.3.2 (old)  checkJarSig -> True
    patched      checkJarSig -> False

End to end on the real artifact: 76,497,574 bytes fetched over https from
repo1, digest matched the pinned value, server started, PDF parsed (200,
11,297 characters), and the cache afterwards contained no checksum file.

getRemoteFile's outDir, where a caller names its own output path, is
deliberately unchanged; it is not a cache and not attacker-predictable.
@coveralls

coveralls commented Nov 9, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 70.13% (+3.0%) from 67.176% — verify-the-server-jar-before-running-it into master

Coveralls was right to complain. The fix added 380 lines and tests for the
main paths, which left 51 new lines unexercised -- including several that
decide security behaviour and had nothing pinning them down:

  - cachedServerJar's local-path branch, where an operator supplies a jar by
    path: it must be checked where it lies, never copied into the cache, and
    refused if it does not match
  - checkJarSig returning True when there is nothing to check against. That is
    a deliberate choice for a private build, and it is the one branch that
    lets an unverified jar run, so it should be impossible to change by
    accident
  - publishedJarDigest's parsing, which is what reads bytes off the network.
    An HTML error page must not be mistaken for a digest -- the old
    getRemoteJar wrote whatever came back straight into the checksum file
  - the cache directory refusals: a symlinked directory, one owned by another
    uid, and a path that cannot be made a directory at all
  - cleanup after an interrupted or rejected download, including a stale
    .part file from a previous crash

Also pinned: that a leftover jar in the shared directory is mentioned once
rather than on every parse, and never deleted; and that with no home directory
at all the cache still does not fall back to the shared directory, which is
the condition the whole change exists to avoid.

40 tests in the file now, 28 added here. Suite 48 -> 68, all passing.
tika/tika.py coverage 49% -> 65% measured locally; 10 of the 380 new lines
remain uncovered and are platform-only or exotic error paths: the Windows
branch of the ownership check (exercised by the windows-latest job), a chmod
that fails, an os.remove that fails during cleanup, and the log-file
permission errors in startServer.

One test needed fixing rather than the code: it built a C:\... path to check
the Windows cache location, and that is not an absolute path when the suite
runs on POSIX, so _defaultCacheDir correctly fell through to its no-home
fallback. The assertion now uses an absolute path in the running platform's
own syntax.
@chrismattmann
chrismattmann merged commit e3228e0 into master Nov 9, 2026
12 checks passed
@chrismattmann chrismattmann self-assigned this Nov 9, 2026
@chrismattmann chrismattmann added this to the tika-next milestone Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants