Repository navigation
Verify the server jar before running it, and stop caching it in a shared directory - #502
Merged
Merged
Conversation
…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 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.
This was referenced Nov 9, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jaron Linux — and was verified against a checksum file at an equally predictable path beside it.checkJarSigfetched the real checksum only when that local file did not exist:So a local user who created both
/tmp/tika-server.jarand/tmp/tika-server.jar.md5before another user's first parse had their jar checked against their own checksum, found authentic, and executed as that user. The sticky bit on/tmpdoes not prevent it — the attacker never overwrites anyone's file, they create it first, which in practice means first after a boot or a/tmpcleanup.The hash algorithm was never the problem.
TIKA_JAR_HASH_ALGO=sha512left 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
http://, fromsearch.maven.orgrather 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. Nowhttps://repo1.maven.org/maven2/.startServeropened/tmp/tika-server.logwithopen(path, 'w'), which truncates: a symlink planted there blanked any file the user running tika could write. The client log usedlogging.FileHandleron/tmp/tika.log, an append primitive by the same route.getRemoteFilewrote URL-fetched documents into the shared directory under predictable, pre-creatable names.What replaces it
184454FAD8697760F3E00D2E4A51A45B944FFD51, checked againsthttps://downloads.apache.org/tika/KEYS.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_SHA512overrides for a private build.os.replace'd into the path that gets executed — so a wrong or half-written jar is never at the name that runs.~/.cache/tika,~/Library/Caches/tika,%LOCALAPPDATA%\tika).TIKA_PATH,TIKA_LOG_PATHandTIKA_FILES_PATHstill override.O_NOFOLLOWand refused if owned by another uid or writable by other users — so pointingTIKA_PATHback at a shared directory is still safe.Scope
On macOS
tempfile.gettempdir()is already a per-user0700directory under/var/folders, so macOS was largely not exposed. On Linux it is/tmp, mode1777. This was a Linux and container problem.An existing
/tmp/tika-server.jaris 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:
and the patched failure is specific:
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.pyadds 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 testscurrently cannot resolve on Python 3.10/3.11 — thedocsgroup wantssphinx>=9.1which needs 3.12+, whilerequires-pythonis>=3.10. Not touched here; I ran the suite in a standalone env. Worth its own change.getRemoteFile'soutDir, where a caller names its own output path, is deliberately unchanged — it is not a cache and not attacker-predictable.