Skip to content

Security: menj/folio

Security

security.md

Security policy

Thank you for taking the time to look into Folio's security.

Supported version

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.

Security controls

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/.

Output encoding

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().

Path containment

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.

File delivery

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.

Authentication

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.

Request forgery

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.

Response headers

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

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.

Browser PDF controls

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.

Derivative images

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.

External utilities

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 addressing

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.

Hover-preview path obscuring

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.

Video access control

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.

PDF access control

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.

Stored state

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.

Deployment expectations

Folio cannot enforce these from inside the application:

  • Serve the site over HTTPS.
  • Keep data/ unreadable from the web. The shipped data/.htaccess denies access on Apache and LiteSpeed. Nginx is not a supported deployment target (see readme.md) and has no equivalent rule maintained by Folio.
  • Delete install.php after 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 reading uploads/. The application root does not need to be writable during normal operation.
  • Set FOLIO_AUTH_PEPPER and keep it out of version control. Changing it later invalidates every stored password.
  • Treat the IndexNow key in data/settings.php as 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.

Not protected

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.

Reporting a vulnerability

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.

What counts as a vulnerability

Yes, please report:

  • Any way to bypass the login, read data/users.php, or read data/settings.php as a public visitor.
  • Any way to read a file that EXCLUDE_PATTERNS should 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_CONFIRMED true and FOLIO_URL_SIGNING_KEY set). 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/ and data/.
  • 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 @id through 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.

Disclosure

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.

There aren't any published security advisories