6 min read

Stirling PDF: 50+ PDF Tools You Run Yourself (GitHub, Scanned)

An all-in-one PDF toolkit for editing, merging, OCR, signing and converting, run locally or self-hosted.

Stirling PDF logo
✅
Scan: safe. Nothing malicious. Two things to know: the licence is open core, with the engine and several app modules under a proprietary user licence, and the Java server's dependencies have no lockfile, so the scan could not check them. Scanned Oct 2, 2026; the full report is below.

Stirling PDF puts more than 50 PDF operations behind one interface: edit, merge, split, rotate, compress, OCR, sign, redact, watermark, add or remove passwords, and convert to and from Office formats, images and HTML. It runs as a desktop app, in a browser against your own server, or as a REST API, and it can chain steps into no-code pipelines for batch work. The point is that documents never go to an online PDF site.

It is one of the most-starred PDF projects on GitHub, at about 93,000 stars, and is now run by Stirling PDF Inc. Its licence needs a careful read: GitHub shows it as unrecognised because the repository is open core. Code outside a set of named folders is MIT, while folders including app/proprietary, the desktop frontend and engine/ are under a proprietary user licence. In practice the free plan covers all PDF operations for up to five users, and larger teams pay.

Who it is for

Anyone who handles contracts, scans or forms and would rather not upload them to an online PDF tool, small offices that want one shared PDF server, and developers who need a self-hosted PDF API.

Getting started

1. Run the server with Docker

docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf

2. Open the web interface

open http://localhost:8080

3. Or build from source with the Task runner

git clone https://github.com/Stirling-Tools/Stirling-PDF.git && cd Stirling-PDF && task dev

Desktop installers and Kubernetes instructions are in the documentation at docs.stirlingpdf.com. Production or business use of the proprietary parts beyond the free plan's limits requires a paid licence.

Safety scan

We cloned Stirling-Tools/Stirling-PDF at commit 302367d on Oct 2, 2026 and ran the checks described on the GitHub Tools page: credential patterns, decode-and-execute code, install-time scripts, committed binaries, risky CI workflows, every host the code talks to, known vulnerabilities in pinned dependencies, and project hygiene. A person read every hit. This is what we found.

  • One secret candidate in 7,963 files and about 1.8 million lines: a throwaway private key in a Java database test under app/proprietary. Of the 21 bare-IP URLs, 20 are tests; the other is the desktop app's connection diagnostic, which sends a HEAD request to Cloudflare's 1.1.1.1 to check whether you are online.
  • Three pattern hits, all routine: an embedded Dockerfile that installs Node.js from NodeSource with curl | bash during an image build, a startup script that checks whether a port is open with bash's /dev/tcp, and a test fixture. The nine installer scripts are test-environment launchers, none using sudo. One committed binary, the standard Gradle wrapper jar.
  • 60 known advisories across 2,081 packages, none critical. The Python engine's uv.lock has pypdf memory and run-time limits on malformed PDFs and urllib3; the Tauri desktop build's Cargo.lock has quick-xml and unmaintained-crate notices; the frontend's package-lock has 23, mostly build tooling. The Java server, the core of the Docker image, uses Gradle without a lockfile, so its libraries were not checked.
  • LICENSE is MIT for most of the tree but assigns app/proprietary, engine/, the desktop frontend and other named folders to the Stirling PDF User License, which limits production use beyond trial and minimal use. GitHub reports the licence as unrecognised for that reason.
  • 43 workflows. Four use pull_request_target and none checks out the pull request; all 171 third-party actions are pinned to commits. Security policy, Dependabot, CodeQL, licence and contributing guide present, and an OpenSSF Scorecard of 7 out of 10.

What the scanner counted

CheckResult
Secrets1 candidate found and read; see the notes above.
Suspicious code3 pattern hits found and read; every one is listed under the raw findings.
Install-time code1 Cargo build script. 9 installer scripts
Committed binaries1 executable or compiled object committed; listed under the raw findings.
CI workflows43 workflows. 4 use pull_request_target, none check out the pull request head. 0 of 171 third-party actions pinned to a tag rather than a commit.
Network hosts40 distinct hosts referenced from source; most often stirling.com, pdf.example.com, github.com, docs.stirlingpdf.com. 21 URLs to a bare IP address, listed under the raw findings.
Known vulnerabilities60 advisories across 2,081 pinned packages: 0 critical, 28 high, 16 moderate, 4 low, 12 unrated. devTools/package-lock.json: 120 packages, 1 advisories; engine/uv.lock: 116 packages, 16 advisories; frontend/editor/src-tauri/Cargo.lock: 667 packages, 21 advisories; frontend/editor/src-tauri/provisioner/Cargo.lock: 12 packages, 0 advisories; frontend/editor/src-tauri/thumbnail-handler/Cargo.lock: 20 packages, 0 advisories; frontend/package-lock.json: 1,302 packages, 23 advisories.
Project hygieneHas security policy, automated dependency updates, CodeQL, licence file, contributing guide.
OpenSSF Scorecard7.0 out of 10, as of Oct 2, 2026.

The raw findings

Every hit the scanner wrote out, with a link to the exact line at the scanned commit. Secrets candidates are redacted.

Secret candidates (1, redacted)
WhereRuleMatch
app/proprietary/src/test/java/stirling/software/proprietary/policy/asset/JpaPolicyAssetStoreDbTest.java:63private-key-----B…--- (27 chars)
Pattern hits (3)
WhereRuleMatch
app/common/src/test/java/stirling/software/common/util/OfficeDocumentSanitizerTest.java:30exfil-host (test/example)private static final String EXTERNAL_URL = "https://webhook.site/ssrf-callback";
docker/embedded/Dockerfile:15download-piped-to-shell&& curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR_VERSION}.x" | bash - \
scripts/init-without-ocr.sh:182raw-socket-shellrun_with_timeout "$timeout_secs" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
URLs to bare IP addresses (21)
WhereRuleMatch
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:137ip-literal-urlassertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue();
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:186ip-literal-urlassertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue();
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:223ip-literal-url"http://100.64.0.1"
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:234ip-literal-urlassertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue();
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:235ip-literal-urlassertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue();
app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java:236ip-literal-urlassertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue();
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:44ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://192.0.2.1"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:45ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://192.0.0.0"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:47ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://198.18.0.1"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:48ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://198.51.100.0"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:49ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://203.0.113.0"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:51ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://100.64.0.1"));
app/common/src/test/java/stirling/software/common/util/GeneralUtilsAdditionalTest.java:52ip-literal-urlassertFalse(GeneralUtils.isURLReachable("http://224.0.0.0"));
app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java:101ip-literal-url"http://54.175.155.236:7952/account-link/callback?state=browser-state",
app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java:102ip-literal-url"http://54.175.155.236:7952",
app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java:139ip-literal-urlconfigureFrontendUrl("http://54.175.155.236:7952/");
app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java:144ip-literal-urlString requested = "http://54.175.155.236:7952/account-link/callback?state=browser-state";
app/proprietary/src/test/java/stirling/software/proprietary/accountlink/ConnectServiceTest.java:147ip-literal-urlrequested, "http://54.175.155.236:7952", "http://localhost:8080");
app/proprietary/src/test/java/stirling/software/proprietary/integration/api/ApiIntegrationValidatorTest.java:39ip-literal-urlassertThatCode(() -> validator.validate(config("https://1.1.1.1/v1")))
app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java:370ip-literal-urlprivate static final String SELF_HOSTED = "http://54.175.155.236:7779";
frontend/editor/src/desktop/services/connectionModeService.ts:1017ip-literal-urlconst response = await fetch("https://1.1.1.1", {
Installer scripts (9)
Committed binaries (1)
  • gradle/wrapper/gradle-wrapper.jar: JAR, 48 KB
Worst known vulnerabilities (24 of 60)
AdvisorySeverityPackageSummary
GHSA-vfj7-8cjw-p6xmhighbraces@3.0.3braces vulnerable to stack-exhaustion denial of service through deeply nested patterns
GHSA-8xx6-hgc6-gc2mhighhttpx2@2.10.0HTTPX2: Streaming response decompression does not bound peak memory (decompression amplification)
GHSA-5jq2-8x83-x246highpypdf@6.16.1pypdf: Possible long runtimes/large memory usage when parsing indirect objects
GHSA-fp3h-c4fm-7vvfhighpypdf@6.16.1pypdf: Possible large memory usage for large /ToUnicode streams (Follow-up 2)
GHSA-g9cg-prrw-2r8qhighpypdf@6.16.1pypdf: Possible large memory usage when parsing font data
GHSA-jw7q-gvrg-4vj3highpypdf@6.16.1pypdf: Possible long runtimes for partially malformed FlateDecode streams (Follow-up)
GHSA-php9-fj8v-98fjhighpypdf@6.16.1pypdf: Possible long runtimes when generating appearance streams
GHSA-qv6h-rv94-w285highpypdf@6.16.1pypdf: Possible large memory usage when retrieving Roman page labels
GHSA-v247-6f48-mgcjhighpypdf@6.16.1pypdf: Possible long runtimes with large amount of embedded files
GHSA-w23x-9jrw-r45chighpypdf@6.16.1pypdf: Possible large memory usage when retrieving alphabetical page labels
GHSA-8988-9cw3-xx77highurllib3@2.7.0urllib3: HTTPS proxy TLS configuration may be ignored or overridden
GHSA-vxq7-64xx-v4gwhighurllib3@2.7.0urllib3: HTTPResponse.stream()/read_chunked() buffers an unbounded chunk-size line into memory
RUSTSEC-2026-0194highquick-xml@0.37.5Quadratic run time when checking a start tag for duplicate attribute names
RUSTSEC-2026-0195highquick-xml@0.37.5Unbounded namespace-declaration allocation in `NsReader` enables memory-exhaustion denial of service
RUSTSEC-2026-0194highquick-xml@0.38.4Quadratic run time when checking a start tag for duplicate attribute names
RUSTSEC-2026-0195highquick-xml@0.38.4Unbounded namespace-declaration allocation in `NsReader` enables memory-exhaustion denial of service
GHSA-3pq3-5fj3-cg6vhighaxios@1.18.1Axios: HTTP/2 adapter bypasses configured DNS lookup and proxy controls
GHSA-542g-h47m-68v8highaxios@1.18.1Axios: Denial of Service via Unhandled 'error' Event in HTTP/2 ClientHttp2Session Initialization
GHSA-c29m-xwm3-cm6rhighaxios@1.18.1Axios: ReDoS in fromDataURI data: URL parser freezes the Node event loop (DoS)
GHSA-m8m8-qj5v-23w3highaxios@1.18.1Axios: Node HTTP adapter prototype-pollution gadget allows request socket hijack via inherited createConnection
GHSA-mghh-pgcx-3jjjhighaxios@1.18.1Axios: ReDoS (O(N²)) in shouldBypassProxy host normalization, reachable via untrusted redirect Location
GHSA-r4gj-5m52-g5whhighaxios@1.18.1Axios: maxRedirects: 0 is not enforced by the fetch adapter, allowing redirect-based SSRF
GHSA-x97p-jq2g-jp4fhighaxios@1.18.1Axios: Prototype Pollution Gadget in axios toFormData Options
GHSA-c475-qrg2-pj4rhighbasic-ftp@5.3.1basic-ftp: Quadratic-time CPU denial of service in Client.list() Unix directory-listing parser (RE_LINE backtracking)
Workflows worth a look

By the numbers

Stars93.5K
Forks10.2K
Contributors314
Commits6,276
Open issues409
Open pull requests172
Releases189
Latest releasev3.0.2
Licencecustom
Main languageJava
Project age3 years
Last pushOct 2, 2026
Tracked files7,963
Lines of code1.8M
Checkout size215 MB

Lines by language: TOML 570K, TypeScript 569.5K, Java 450.8K, JSON 54.2K, CSS 42.3K, Python 39.6K.

Questions

Is Stirling PDF free?

For personal use and small teams, yes. The free plan is self-hosted, includes every PDF operation and OAuth2 single sign-on, and allows up to five users. The Team plan is $99 a month or $999 a year for 100 users, and Enterprise (custom pricing) adds SAML, audit logs, air-gapped licensing and SLAs.

Is Stirling PDF open source?

Partly. It is open core: most of the repository is MIT, but named directories, including the engine, desktop frontend and proprietary app modules, carry a Stirling PDF User License that restricts production use to licence holders beyond trial and minimal use. Check the LICENSE file before building a product on it.

Does Stirling PDF send my files anywhere?

No. When you self-host it or use the desktop app, processing happens on your machine or server, and the project's pitch is that documents never go to an external service. Optional integrations such as Google Drive are paid-plan features you would have to enable.


This post is part of GitHub Tools, where every repository is cloned and scanned before it is written up. The scan is a snapshot of one commit on one day; the repository has moved on since, so check it before you install.