Thank you for taking the time to look into Folio's security.
Folio is a small single-file application shipped as a numbered release. Only the most recent release receives security fixes. If you are running an older release, upgrade before reporting an issue.
The current supported release is 1.50.15.
This section records what Folio actually enforces, so that a future change
does not quietly remove a protection. Each item is covered by the regression
suite in tests/.
Structured data is JSON, but it is written inside a <script> element, which
is an HTML context. It is therefore encoded with JSON_HEX_TAG,
JSON_HEX_AMP, JSON_HEX_APOS, and JSON_HEX_QUOT, and not with
JSON_UNESCAPED_SLASHES. A document title containing a closing script tag
must never be able to end the element. If encoding fails, an empty graph is
emitted rather than a malformed one. All page types share one encoder.
Markdown, from both uploaded .md files and standalone pages, is rendered by
Parsedown in safe mode, so raw HTML in that content is escaped rather than
executed. Every other value interpolated into a page goes through e().
Every path derived from a request is resolved and confirmed to sit inside
uploads/. Symbolic links are rejected outright rather than followed, at
every directory scan and at each delivery endpoint. Files matching
EXCLUDE_PATTERNS are treated as absent: they are missing from the listing,
sitemap, categories, and IndexNow submissions, and requesting one directly
returns 404 rather than serving it.
Formats a browser will execute in the page's own origin — HTML, XHTML, XML,
MHTML — and any unrecognised type are forced to application/octet-stream
with Content-Disposition: attachment, a sandboxing Content-Security-Policy,
and X-Robots-Tag: noindex. This is enforced both in PHP and in the shipped
uploads/.htaccess, so neither alone is a single point of failure. SVG is
served under a sandboxing policy so embedded script cannot run.
Passwords are stored as password_hash() digests, optionally peppered with
FOLIO_AUTH_PEPPER. Each account carries an auth_version that is
incremented when its password is changed or reset, or when the account is
deleted; existing sessions carrying an older value are rejected on their next
request. Sessions are only started for requests that need one, so anonymous
public pages stay cacheable and set no cookie.
Failed logins are counted per address, and the entire read-modify-write is performed while holding an exclusive lock, so parallel attempts cannot overwrite one another's increments.
Limitation, stated plainly: this throttle is a speed bump, not
brute-force protection. It is keyed by REMOTE_ADDR, so an attacker with many
addresses is not meaningfully slowed, and its counters live in the system
temporary directory, which some shared hosts clear or isolate per process.
Treat a strong password and HTTPS as the actual controls, and add rate
limiting at the web server or CDN if the library is a likely target.
Every state-changing action requires a POST carrying a valid CSRF token:
metadata edits, settings, crawler controls, standalone pages, account
management, and logging out. Logging out is included deliberately — a plain
GET logout lets any third-party page end an administrator's session, so the
old URL now returns 405 with a confirmation form instead of acting.
Application responses carry a Content-Security-Policy with no unsafe-inline,
X-Content-Type-Options: nosniff, a referrer policy, and framing
restrictions. There are no inline event handlers or inline <script> blocks
anywhere in the application and all of its own JavaScript is in external
files, so the policy needs no exceptions for Folio's own code.
The installer emits the same protections plus frame-ancestors 'none' and
no-store caching, because its pages display one-time tokens and generated
secrets.
The PDF flip-view reader is the one screen that relaxes the policy, adding
wasm-unsafe-eval and worker-src 'self' because WebAssembly rendering
requires them. It is a separate page for exactly this reason: the listing and
every other screen keep the stricter policy.
Analytics is off unless a provider is configured. With MATOMO_URL,
MATOMO_SITE_ID, and GA4_MEASUREMENT_ID all empty, no third-party origin
appears in the policy and no tracker tag is emitted: the responses are
identical to a build without the feature.
When a provider is configured, the policy is widened to exactly the origins
that provider needs, carrying the port through when the URL specifies one.
Both providers require a short inline bootstrap. Rather than allow inline
script generally, each block is admitted by its own sha256 hash, computed
from the same string that is emitted. Enabling analytics therefore does not
weaken inline-script protection on any page.
A mismatched hash is a silent failure — the tag renders, the policy still
looks strict, and the tracker never runs — so the regression suite asserts
that every emitted block is covered by a hash and that 'unsafe-inline' never
appears.
Analytics is not applied to authenticated sessions unless ANALYTICS_ADMIN is
enabled, so administrators' own browsing stays out of the figures and out of
the third party's records.
Folio itself records nothing: no visit log, no IP addresses, no geolocation. Anything collected is collected by the configured provider under its own terms, which is a decision for whoever runs the site.
Embedded PDFs ask the browser to drop its own toolbar, so its download and print buttons do not sit on top of Folio's. That is presentation only. The parameters are advisory, several browsers ignore them, and the file's URL is reachable whatever the viewer displays.
Nothing about hiding a button restricts access. A document that must not be
downloaded needs pdf_access set to restricted or hidden, which is enforced when
the request reaches the server.
Generating a thumbnail means handing an uploaded file to an image decoder, which is a meaningful attack surface: decoders are complex, and ImageMagick's delegates in particular have a long CVE history. The feature is therefore bounded rather than trusted.
Dimensions are read with pingImage() before any pixels are decoded, and
anything beyond IMAGE_MAX_PIXELS is refused. This is what stops a
decompression bomb — a small file declaring enormous dimensions — from
exhausting memory, and it costs nothing because the image is never decoded.
Memory, wall-clock, and thread ceilings apply to every conversion.
The delivery route honours only the widths in THUMB_WIDTHS. Without that, a
visitor could request thousands of arbitrary sizes and fill the disk. It
enforces the same path containment and exclusion rules as every other delivery
route, so an excluded file has no derivative and returns 404 rather than
leaking through a second door.
Derivatives are written only under data/, which is denied to the web and
served through PHP. Uploaded files are never modified and never written to.
Generated images are stripped of metadata, so EXIF GPS coordinates and camera
serial numbers are not republished in a public thumbnail.
SVG is never rasterised. It is already a web format and can contain script, so it continues to be served under the sandboxing policy that applies to active formats.
PDF_SERVER_PREVIEW is off by default because rasterising a PDF invokes
ImageMagick's PDF delegate, which has a poor security record. Folio's in-browser reader previews PDFs without it, so
the default costs nothing. Turn it on only for libraries whose documents you
control.
If no image engine is installed, every part of this is inert and the original file is served exactly as before.
A PDF's page-one thumbnail is never rasterised from the original file when the document has redaction regions set. It is rasterised from the same redacted derivative the document itself serves, so a black box drawn over page one cannot be bypassed by hovering the file in a listing. If the redacted derivative cannot be built, the thumbnail route serves no preview at all rather than ever falling back to the unredacted page — the same fail-closed rule that governs the redacted PDF delivery itself.
A video's hover/listing preview — one extracted frame, and where ffmpeg
supports it, a short silent looping clip — is generated the same way and
under the same bounds as the PDF preview above: a source file over
VIDEO_PREVIEW_MAX_SOURCE_MB is skipped rather than encoded, and every
ffmpeg invocation carries the timeout and process-boundary protections
described in External utilities below. The moving clip strips audio
unconditionally (-an), so nothing beyond silent video is ever generated or
served from this route. It is offered for a video whose video_access is
public, and for any tier to the admin — the same viewer who already gets a
full, unrestricted player for that file regardless of tier; a restricted or
hidden video gives a public visitor no listing preview, moving or still, the
same way it already gives them no direct download link.
A video's hover preview can carry its own redaction, independent of
video_access: one or more boxes drawn over the reference frame, blurred
into both the still frame and the moving clip. This affects only the hover
preview — the small poster and the short clip — never the full video played
from the document page, which is unrestricted for whoever the access tier
already permits. Like the PDF and image thumbnail paths, this is generated
through ffmpeg's own filter graph (crop, blur, overlay back at the same
position) rather than composited from a separate, potentially inconsistent
step, so there is no code path that could emit the unblurred frame by
mistake: if the filtered render fails for any reason, the function returns
nothing rather than falling back to an unfiltered one. The cache key for
both the still frame and the clip folds in a hash of the redaction regions,
so editing or clearing them invalidates the cached derivative rather than
continuing to serve one built under the previous settings.
Folio can call command-line programs — OCRmyPDF, Tesseract, Poppler, pngquant, ffmpeg — when a server provides them. That means user-controlled filenames reach a process boundary, so the mechanism matters more than the feature.
Programs are started with proc_open() given an argument array, with
bypass_shell set. No shell is spawned, so shell metacharacters in a filename
are not special: a file named x.pdf"; rm -rf /; echo " is passed to the
program as that exact literal name and treated as a missing file. This is what
makes it safe for Folio, whose filenames arrive over FTP and are never
sanitised by the application, to call external programs at all. shell_exec,
exec, system, passthru, and backticks appear nowhere in the codebase,
and the regression suite fails the build if any of them is introduced or if
proc_open is used without bypass_shell.
Utilities are located only in the directories listed in TOOL_SEARCH_PATHS,
or named outright in TOOL_PATHS. $PATH is deliberately not consulted: it is
inherited from whatever started PHP, and on shared hosting it is not something
worth trusting to decide which binary runs. A utility that is not found
resolves to null, never to a bare command name, so a failed lookup cannot
become a $PATH-resolved execution. Names are validated against a strict
pattern even though they are internal constants.
Every invocation carries a timeout and an output cap, so a malformed or hostile document cannot hang a request or exhaust memory. Utilities inherit a minimal environment rather than the request's.
OCR is admin-only and CSRF-protected, and applies the same path containment and exclusion rules as every other action. It is never triggered by a visitor's page load: it takes seconds to minutes, and that is not work to do inside a page request.
PDF pages are rendered with Poppler, which reads PDFs directly. ImageMagick's
own PDF support is reached only when PDF_ALLOW_GHOSTSCRIPT is explicitly set
to true; it defaults to false, and the regression suite fails the build if
that default changes or an unguarded ImageMagick PDF read appears. Both OCR
routes avoid it as well.
With that setting false and Poppler absent, PDF previews are simply not generated and the original file is served — a missing capability, not an error.
Canonical URLs, Open Graph URLs, sitemap entries, and JSON-LD identifiers are
all built from the configured SITE_URL. The request Host header is never
trusted, so it cannot be used to poison a cached page or a structured-data
identifier. X-Forwarded-Proto is honoured only when TRUST_PROXY_HEADERS
is explicitly enabled, which should only be done behind a proxy that
overwrites that header.
A restricted or hidden video's hover-preview thumbnail and moving clip are only ever shown to an admin — but the URL for them still ends up written into the page's own HTML, and under the default delisting-only model (guard off, the common case), that URL is the file's own literal, permanently-guessable direct path. If that HTML were ever exposed — screenshotted, cached, scraped by a browser extension — the path itself would be exposed with it, regardless of anything the video access-control guard does.
video_obscure_path()/video_unobscure_path() close this specifically:
AES-256-GCM, authenticated so a tampered token fails outright rather than
decrypting to a different, wrong path, under a dedicated key
(data/.obscure-key) generated once on first use and never requiring
manual setup — unlike FOLIO_URL_SIGNING_KEY, this protects every install
by default, not only ones that have opted into the PDF or video gate.
?action=thumb and ?action=video_preview try to decode the file
parameter as an obscured token first, falling back to treating it as a
plain path if that fails — so an ordinary link (public content, or any
install from before this existed) keeps working unchanged, and a public
video's own hover preview stays a plain, cacheable URL, since there is no
secrecy benefit to obscuring a path anyone can already see linked. Verified
with a real forged/tampered token (rejected) and a real round trip through
the live routes (an obscured token correctly still resolves to and serves
the right file).
Not fixed by this: the admin's own "Preview" button still links to the
file's bare direct URL, because it points at a file Apache serves directly,
with no PHP route in the path to decode an obscured reference against.
Tracked in docs/upgrading.md's roadmap, Phase 3.
By default, video follows a delisting-only model: a restricted or hidden video's URL is simply never emitted to a visitor who shouldn't have it — the bytes themselves are still directly reachable by anyone who already holds the exact URL. This is a deliberate, settled trade-off (documented in the Crawlers screen: "not secret from someone who already holds its direct URL... for a personal archive this is usually the right balance of speed and privacy") and is not what's described below.
An opt-in guard (video_guard_active(), mirroring pdf_access_enforced()'s
exact two-condition shape) exists to move video from that delisting-only
model to one enforced at the webserver: with it on, direct requests for any
video file are refused unconditionally by Apache, and every video is instead
served through ?action=raw, signed the same way a restricted PDF already
is — hash_hmac('sha256', ...) under FOLIO_URL_SIGNING_KEY, verified with
hash_equals, a short expiry, hidden refused unconditionally. Enforcement
requires both a non-empty FOLIO_URL_SIGNING_KEY and VIDEO_GATE_CONFIRMED,
a setting turned on only from the Crawlers screen after its own preflight
succeeds — the same fail-safe shape as PDF_GATE_CONFIRMED, checked live on
every request rather than trusting a stale flag, so a signing key cleared
after the fact immediately stops being trusted.
Confirming is a two-step, verify-before-trust flow, the same reasoning as
the PDF gate's own preflight but adapted for a real difference: a PDF's
preflight is read-only (does a request already reach ?action=raw?), while
testing video's rule requires writing it first — there is nothing to
observe about a deny rule that has not been written yet. The "Test video
routing" button calls an admin-only endpoint that performs a true dry run:
write the rule, make an outbound request to a reserved probe file and check
for a genuine 403, then unconditionally undo the write before responding,
whatever the result. That endpoint can never be the thing that leaves
Apache refusing video while Folio still believes the guard is off, because
it never leaves a change in place. Only "Confirm and enforce" — a separate,
independently-verified POST — makes a persistent change, and even that
rolls back the .htaccess write if its own verification fails, so a
half-succeeded confirmation can never leave the two sides disagreeing about
whether video is actually being blocked.
An earlier version of this mechanism had .htaccess check a token's shape
— 64 hex characters, an 8-or-more-digit expiry — rather than its signature,
because mod_rewrite has no way to compute or hold an HMAC key at all. A
regex can only confirm a token looks right, never that it is right, so
anyone who noticed the shape being matched could construct a passing query
string with no knowledge of the actual key. Compounding it, nothing in
Folio ever generated a matching token for a direct video URL in the first
place, so enabling the guard would have simultaneously let a forged request
through and refused every legitimate one, including the admin's own. Fixed
by removing every conditional path from the .htaccess rule — it now
refuses unconditionally, with no shape it can be tricked into accepting —
and building the same real, PHP-verified signing PDF's restricted tier
already used correctly. A second, unrelated piece of code — predating this
fix, on the Crawlers screen — automatically cleared the guard's state every
time that screen loaded, which would have silently undone an admin's own
confirmation the moment they landed back on the same page after confirming
it. Removed; its original justification (forcing video through PHP causing
slow buffering for a logged-in visitor) was a session-locking bug fixed
separately and is no longer applicable.
Verified end to end against a real running instance, not just read: a forged token, an expired token, a missing token, and a hidden video with any token all correctly refused; a genuine valid token correctly served the file with Range support intact; the confirm handler correctly refuses to enable enforcement when its own verification shows the rule isn't actually blocking anything, with a clear reason why; the guard's state now persists across repeated page loads rather than resetting; and disabling correctly restores the default delisting-only model.
A second cause of the same "gated video buffers, public video doesn't"
symptom was found afterward: stream_file_bytes(), the function this gate
routes non-public video through, never raised PHP's execution-time limit
before streaming. Public video is served directly by the webserver and
never touches PHP at all, so it was never exposed to this; a gated video
of any real size or over a slow connection could easily outlast a shared
host's default max_execution_time (commonly 30s), and PHP would kill the
process mid-transfer rather than finish sending it — indistinguishable at
the player from ordinary buffering that simply never resolves. Fixed with
an uncapped set_time_limit(0) at the top of the function, covering both
of its call sites (the gated-video fallback and the cached hover-preview
clip) in one place.
A PDF's pdf_access (public/viewer/hidden) is enforced at exactly one place,
?action=raw, which every other path to a PDF's bytes (detail-page preview,
flip-view reader, print, direct link, hover preview) is built to route
through rather than duplicate the check. "Restricted" PDF URLs are signed with
hash_hmac('sha256', ...) under FOLIO_URL_SIGNING_KEY, verified with
hash_equals, and carry a short expiry; "hidden" is refused unconditionally,
with no valid URL of any kind.
This is a fail-safe design, not a fail-open one: enforcement requires both a
non-empty FOLIO_URL_SIGNING_KEY and an explicitly confirmed routing
preflight (PDF_GATE_CONFIRMED, set only after the Crawlers screen verifies
that requests to a real file actually reach ?action=raw). Absent either
condition, every PDF behaves as public regardless of its stored setting,
with a visible warning in Diagnostics and in each affected file's editor.
The alternative — restricting based on a setting that might not actually be
enforced on a given server — would be a false sense of security, which is
worse than no restriction at all.
The preflight itself makes one outbound HTTPS request, from the server to
its own SITE_URL, to verify the rewrite independently rather than trust
the browser's report alone (the same pattern the IndexNow submission already
uses). This request is admin-authenticated (only reachable through the
Crawlers screen's POST handler) and targets only the configured SITE_URL,
never a value derived from the request; see "Canonical addressing" above for
why that's safe from Host-header injection. If the request fails outright —
common on hosts that block outbound HTTP — Folio falls back to trusting the
browser's own successful probe rather than blocking the feature entirely.
This feature requires Apache or LiteSpeed (see "Deployment expectations" below — Nginx isn't a supported deployment target at all). The preflight above simply never confirms on an unsupported server, so every PDF continues to behave as public — the same fail-safe fallback as any other unconfirmed server, never a silent gap.
Accounts, settings, metadata, and standalone pages are written through a
locked, atomic replace: write to a temporary file, fsync, rename into place,
and retain a last-known-good .bak. A malformed or partially written store is
rejected rather than adopted, and the previous good copy is preserved.
Known weakness: data/users.php and data/settings.php are PHP files
loaded with include. They are runtime-writable, so any unrelated
arbitrary-file-write flaw could be escalated to code execution. Moving these
to a non-executable format is planned. Until then, keeping data/ outside the
document root, or read-only except when settings are being changed, is the
strongest available mitigation.
Folio cannot enforce these from inside the application:
- Serve the site over HTTPS.
- Keep
data/unreadable from the web. The shippeddata/.htaccessdenies access on Apache and LiteSpeed. Nginx is not a supported deployment target (seereadme.md) and has no equivalent rule maintained by Folio. - Delete
install.phpafter installation, and confirm the Diagnostics screen reports it as removed. - Give the web-server account the narrowest ownership that still allows
writing to
data/and readinguploads/. The application root does not need to be writable during normal operation. - Set
FOLIO_AUTH_PEPPERand keep it out of version control. Changing it later invalidates every stored password. - Treat the IndexNow key in
data/settings.phpas a secret; it is excluded by the shipped.gitignore. - If you rely on PDF access control ("restricted"/"hidden"), confirm the
preflight on the Crawlers screen after every deploy that touches
.htaccess, and re-confirm after moving hosts. It fails safe to public rather than silently, but a restriction you believe is active and isn't is still worth catching promptly.
Folio is fundamentally a public library: every account has full
administrative authority, and anyone with FTP access to uploads/ controls
the library's contents, which is the intended workflow rather than a flaw.
Documents are public by default and remain so unless explicitly restricted.
The one exception is PDF access control (above): a PDF explicitly set to
"restricted" or "hidden," on a server where that's confirmed enforced, is not
reachable at its plain URL. Video is deliberately different and lighter. Its
"restricted" and "hidden" tiers delist a clip rather than withhold it. The
player and the file's URL are not shown to the public, who see a notice in
place of the player, yet the file itself is served directly by the webserver
and is not gated: there is no webserver block, no signed URL, and no
byte-level check. The direct URL is derived from the filename, so a restricted
or hidden video must be treated as reachable by anyone who has, or can
construct, that URL, and any clip that was public before it was hidden is
already crawled and cached. This is intentional, an accepted trade-off for
direct-serve playback speed (see changelog 1.38.0), and it is the settled
design: video tiers hide a clip from view, they do not make it private. A
video that must be private should be kept out of the library entirely (for
example with EXCLUDE_PATTERNS) or restricted at the webserver, rather than
relying on the tier. Every other file format has no per-file access control.
EXCLUDE_PATTERNS can hide a file from listings and public URLs entirely, and
there is no partial-access tier beyond PDFs and video.
Note that a "restricted" document's detail page is intentionally public and indexable — that is the point of the tier: the page (title, description, transcript, structured data) is meant to be found in search, while the file itself is withheld. A crawlable page for a restricted document is by design, not an exposure. A "hidden" document's page is also indexable (so it can be found via search) but is removed from the folder listing, so the public reaches it only through a search engine or direct link.
Please do not open a public issue or pull request for security problems.
Instead, send a private report to the maintainer with the details. Include:
- A description of the issue and its impact.
- The Folio version, PHP version, and web server (Apache, LiteSpeed, Nginx).
- Steps to reproduce, or a proof of concept if you have one. A minimal HTTP request or a short script is ideal.
- Whether the issue is already public and, if so, where.
You should receive an acknowledgement within a few days. If you have not, please follow up.
Yes, please report:
- Any way to bypass the login, read
data/users.php, or readdata/settings.phpas a public visitor. - Any way to read a file that
EXCLUDE_PATTERNSshould hide. - Any way to reach a "hidden" PDF's bytes, or a "restricted" PDF's bytes without a
valid, unexpired signature, on a server where PDF access control is
confirmed enforced (
PDF_GATE_CONFIRMEDtrue andFOLIO_URL_SIGNING_KEYset). A server where enforcement is not confirmed is expected to serve every PDF as public — that is not a vulnerability, it's the documented fail-safe default. This applies to PDFs only: reaching a "restricted" or "hidden" video at its direct URL is expected and intentional (the video tiers delist a clip, they do not withhold it, as described above), so it is not a vulnerability and does not need reporting. - Any way to read or write files outside
uploads/anddata/. - Any way to execute arbitrary PHP or shell commands.
- Any way to make Folio serve an active file format (HTML, XML, MHTML) inline same-origin instead of forcing an attachment download.
- Any way to poison a canonical URL, an Open Graph URL, a sitemap entry, or a
JSON-LD
@idthrough the request Host header or another untrusted input. - Any way to corrupt or delete the metadata store, the settings store, the accounts store, or the pages store, or to observe a partial write from another request.
- Any way to keep an authenticated session alive after the account is deleted, its password is changed, or its password is reset.
- Anything that would let a stored file preview or a stored page body execute script in a visitor's browser.
- Anything that could let an anonymous request cause outbound HTTP from the
server, other than the deliberate Bing ping, IndexNow submit, and PDF
access-control preflight self-verification, which are admin-authenticated
and target hardcoded, allowlisted hosts or the configured
SITE_URL.
Not a Folio issue:
- Vulnerabilities in a PHP extension, in Apache, LiteSpeed, or Nginx.
- Vulnerabilities in Parsedown itself (report those upstream). Parsedown is run in safe mode, so raw HTML in Markdown is escaped.
- Issues that require an attacker to already have FTP access to the server. FTP is Folio's deliberate content-upload channel; the trust boundary is at the FTP account.
- Denial of service through large uploads or unbounded requests. Rate limiting belongs at the web-server or CDN layer.
- Missing features that would improve defense in depth but do not cross a trust boundary. Suggestions welcome as regular issues.
Once a fix is prepared, we will coordinate a release with you. A brief credit will be added to the changelog unless you ask to remain anonymous.