Security hardening: fix code execution, XSS, CSRF, auth and upload issues - #11
Merged
Merged
Conversation
Renaming an uploaded file did not check the new name's extension, so an image with embedded PHP code (which passes the MIME check) could be renamed to .php and then executed via the statically served images folder. Restrict new names to the valid upload extensions, strip any path components, and refuse to overwrite existing files on rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Add .htaccess files so that page sources and a potential .git folder in PAGES_PATH can't be fetched directly (bypassing the password and IP checks), so that nothing in the uploads folder can be executed, and so that uploaded SVG files are served with a sandboxing CSP. Protect hidden files in the uploads folder (e.g. its .htaccess) from being renamed, deleted or overwritten through the wiki, and document equivalent nginx rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
SVG files can contain scripts that run in the wiki's origin when opened directly, so remove them from the default list of allowed upload types. If they are enabled anyway, don't pass them to ImageMagick for resizing, as its SVG handling can resolve external references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Page names, search queries, file names, form values and messages were printed into the HTML unescaped, allowing reflected and stored XSS (e.g. via ?action=search&q=<script>..., page names in the URL, upload file names, or "</textarea>" in page content opened in the editor). Add an h() helper wrapping htmlspecialchars and use it wherever such values end up in the page, also for untranslated labels returned by __(). The edit form now always posts to SELF, since the page name is part of the form data anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
The Markdown parser escapes '<' and '&' but not quotes, so the {{image}}
shorthand and the anchors generated for headings allowed breaking out of
HTML attributes (e.g. {{x" onerror="alert(1)}}). Escape both properly.
Also filter link and image URLs through the parser's url_filter_func so
that only safe schemes (http, https, mailto, ftp, ftps, tel) or relative
URLs are emitted; others such as javascript: or data: are replaced by #.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
There was no protection against cross-site request forgery; since saving read its parameters from $_REQUEST, even a plain GET request (e.g. an <img> tag on another site) could overwrite pages. Add a per-session token to all forms that modify data, and require both a POST request and a valid token for saving, uploading, renaming and deleting pages and files. The logout link also carries the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
The search query as well as page and image names were interpolated into regular expressions unquoted, allowing regex injection: expensive patterns (ReDoS), PHP warnings, and link rewriting across all pages going wrong on rename/delete. Page and file names in replacement strings could also inject backreferences. Use preg_quote for names in patterns, escape replacement strings, match link targets non-greedily so deleting a page doesn't remove other links on the same line, and do a plain case-insensitive substring search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
- Support password_hash() hashes in W2_PASSWORD_HASH (the legacy SHA-1 hash is still accepted) and compare in constant time; the previous loose == comparison of SHA-1 hex strings was open to "magic hash" collisions such as 0e... values - Refuse logging in while the well-known default password is configured - Regenerate the session ID on login to prevent session fixation, and enable session.use_strict_mode - Set HttpOnly and SameSite=Lax (and Secure on HTTPS) on the session cookie - Log failed login attempts and slow them down to hinder brute-forcing Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
api.php neither checked the password nor the IP allowlist, and passed the requested file name unfiltered to file_exists, which allowed anyone to probe for the existence of arbitrary files on the server (e.g. via filename=../../../etc/passwd). Move session setup, IP restriction and password checking into auth.php, shared by index.php and api.php, and let api.php reject requests that are not logged in. Only check plain file names within the uploads folder, and URL-encode the file name in the upload form's request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
The IP allowlist compared string prefixes, so e.g. an entry "10.0.0.1" also allowed 10.0.0.10-19 and 10.0.0.100-199, and "192.168.1" allowed 192.168.10.x etc. Entries now match exactly, as CIDR ranges (IPv4 and IPv6), or as explicit prefixes if they end in "." or ":". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Uploads silently overwrote existing files; the overwrite confirmation only happened client-side. Now the upload form only sets an overwrite flag after the user confirmed, and the server refuses to replace an existing file (also the target of a format conversion) without it. Also clamp the requested maximum image size to the range offered by the form (20-8192 pixels) instead of trusting the submitted value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Error messages shown to users contained absolute filesystem paths as well as the failed git command and its output. Show generic messages instead and write the details to the web server's error log. Also shell-escape PAGES_PATH in the git commands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Page names may contain '/' to create pages in subfolders, which also allowed storing pages in the uploads folder (served statically, i.e. readable without login) or in hidden folders such as .git. Refuse such names, and empty path segments, when creating or renaming pages. Also don't let renaming a page silently overwrite another existing page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Missing braces meant only the getAllPageNames() call was conditional, while the loop over all pages always ran (on an undefined variable if the option was disabled). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Page names from PATH_INFO and request variables are already decoded by the web server/PHP, but were passed through urldecode() once more. This compensated for pageURL() encoding spaces as '+' (which is a literal plus in a URL path), but mangled names containing '+' or '%' (e.g. "A+B" became "A B", "x%41y" became "xAy"), and made the page actually operated on differ from the one the user saw. Encode page URLs with rawurlencode, drop the extra decoding (also for the image and previous page parameters), and redirect old links with '+' for spaces to the correct page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Make the class autoloader ignore classes without a file, so that class_exists() can be used to probe for optional libraries instead of failing fatally, and load Composer's autoloader if present. Add svgUploadsAvailable() and validUploadTypes()/validUploadExts() helpers, which add SVG to the allowed uploads only if SVG_UPLOADS_ENABLED is set (new config option, off by default) and the enshrined/svg-sanitize library is installed. SVG uploads are still refused for now, since sanitizing them on upload is added separately. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Allow uploading SVG files again if SVG_UPLOADS_ENABLED is set and the enshrined/svg-sanitize library is installed. Uploaded SVGs are passed through the sanitizer, which removes scripts, event handlers, javascript:/data: URLs, foreignObject content, external references and stylesheet imports; files it doesn't accept as valid SVG, or that are larger than 1 MB, are rejected. SVG content in files with other extensions is rejected as well. Without the library SVG uploads stay refused (fail closed). The library is GPL-2.0-or-later, so it isn't bundled with the MIT licensed W2 but installed with Composer (composer.json suggests it). Add a vendor/.htaccess denying web access to installed libraries, and document setup in INSTALL.md and README.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
The rename/delete forms used "${action}d", which PHP 8.2+ reports as a
deprecation on every request showing these forms (noticed by the new
integration tests checking the error log).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Tests run a private copy of the wiki with an individual config.php on PHP's built-in web server and talk to it over HTTP with a small cookie-aware client. Each test starts from the initial pages, without uploads and with a new session, and fails if the app logged PHP warnings, notices or deprecations. W2_APP_ROOT allows running the suite against another checkout (e.g. to see that tests fail without the fixes). Run with "composer install && composer test". Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Covers reflected XSS (search, page names, parameters, forms, editor), stored XSS through Markdown (table of malicious inputs checked by parsing the resulting HTML for scripts, event handlers and javascript:/data: URLs), escaped file and page names, that safe links still work, and that all state-changing actions require POST and the session's CSRF token. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Cover URL round trips of page names with special characters (no double decoding, redirect for old "+" links), refused page names, renaming and deleting pages with link updates (special characters are literal, other links stay untouched, other pages are never overwritten), literal handling of search queries and image names, and quick return for catastrophic regular expressions. Also let csrfToken() return an empty token for versions without CSRF protection, to check the tests fail without the fixes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Cover valid uploads and listing, file name handling (spaces, paths), refused script/hidden/disguised files including SVG by default, the overwrite confirmation, renaming and deleting images (extension check, no overwriting, no paths, hidden files protected, page references updated), the usage column, sorting, and the disabled-uploads and no-usage-column configurations. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
checkupload answered "true" for "." (the uploads folder itself) and for hidden files such as .htaccess. Found by the new API tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
…views Cover logging in with password_hash, SHA-1 and plain passwords, refusal of the default password and of "magic hash" collisions, hidden wiki content, slowed-down failed logins, session regeneration and cookie flags on login, logout, the api.php login requirement and traversal checks, the IP allowlist (allowed and refused addresses), and a regression suite for all main views and workflows. W2_IGNORE_PHP_LOG allows running the suite against old versions that log PHP errors. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Add a workflow that checks PHP syntax and validates composer.json, and runs the integration test suite, on PHP 8.1 to 8.4 for pushes to main and pull requests. Document how to run the tests, and remove the stale Travis CI Apache configuration. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Add enshrined/svg-sanitize as a development dependency (it stays optional and unbundled for users) and let the test harness install it into the tested app like Composer would. The new "svg" suite uploads 22 fixtures with scripts, event handlers, javascript:/data: URLs, external references, entities, processing instructions, foreignObject and non-SVG content, and checks that each is refused or stored without anything dangerous; that harmless files are kept; that SVG content in other file names, huge or malformed files are refused; and that uploads stay refused if the library is missing or the feature is not enabled. Also runs on CI. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
….php index.php did everything on include (sessions, output), so its helper functions could not be tested on their own. Move the helpers without side effects, unchanged, into functions.php (escaping, file and page names, URLs, Markdown to HTML, upload checks) and auth_functions.php (CSRF tokens, IP allowlist, password check, login state), which do nothing when loaded. Two small changes come with this: the class autoloader resolves files relative to its own folder instead of the current directory, and isCorrectPassword() takes the configured hash and password as optional parameters (defaulting to the constants from config.php). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Test escaping, URL and file name handling, page and upload name validation, URL scheme filtering, the Markdown conversion (including the table of script injection attempts also used by the integration tests), the IP allowlist matching (IPv4, IPv6, CIDR ranges, prefixes, malformed entries), password checking (password_hash, SHA-1, plain, default and "magic hash" passwords) and CSRF tokens. Share HTML assertions between unit and integration tests, and run the unit tests on CI for PHP 8.1 to 8.4. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Apache installations like Debian's enable directory listings by default, which would list all uploaded files (and the folders of the wiki) when their folder is requested. Found by the new web server tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Run the wiki behind php:apache (with the .htaccess files) and nginx with php-fpm (with the rules from INSTALL.md) in Docker containers, and check over HTTP that page sources, .git, vendor and hidden files are not served, that scripts in the uploads folder are never executed (also with double extensions and through the pages folder), that uploads have nosniff and SVGs a sandboxing CSP, that folders are not listed, that source files are not disclosed, and that uploading works end to end. Run by tests/Server/run.sh, and on CI for both servers. A unit test keeps the tested nginx rules identical to the ones in INSTALL.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Try to run scripts in a real browser: the script injection texts of the PHP tests as stored page content, reflected through URLs and forms, uploaded file names, the editor, and all SVG upload fixtures opened directly. Any dialog or event handler that runs fails a test; a canary test makes sure the helpers notice scripts. With W2_BROWSER=1, tests/Server/run.sh also runs them against Apache or nginx, including a check that scripts in old SVG files in the uploads folder are blocked by the server headers (with a control file that shows scripts do run elsewhere). tests/bin/serve-app.php starts the app for the tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Add a job running the browser tests against the wiki on PHP's built-in server (including SVG uploads), and let the web server jobs also run the browser tests against Apache and nginx. Upload the Playwright report if a test fails. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Use PHP's tokenizer to check that the wiki's files don't call functions which run code or commands (exec() only in checkedExecute()), don't include files by variable names, only put values into regular expressions and replacement strings when they are quoted, and only read request data in the entry points. This would have found several of the problems fixed earlier. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
Takes over "Error check / fix prevpage storage" from main, which only accepts existing pages as the page to return to after uploading or changing images. Conflicts in index.php, where the functions it touches had been moved to functions.php, and where the same lines were already escaped here, are resolved as follows: - main's isValidPageName() (does the page exist) is renamed isExistingPage() and moved to functions.php, as isValidPageName() already checks the syntax of new page names here - requireValidPreviousPage() stays in index.php, but doesn't urldecode() the value again: request values are already decoded, which would turn a page like "A+B" into "A B" (fixed earlier on this branch) - the upload link in the toolbar falls back to the default page if the current page doesn't exist (yet), otherwise the upload page of a page being created would fail with "Invalid page name" - the forms keep escaping with h() and the CSRF token / overwrite fields Tests are adapted (invalid pages are now refused instead of being shown escaped) and extended for the new behavior. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
wiki.js, which asks for confirmation before leaving the editor with unsaved changes, was only loaded when editing existing pages. Typing a new page and clicking e.g. the upload icon silently lost the text. Load it for new pages too, and track the title field as well. Also make the script URL absolute: it was relative, so on addresses like /index.php/Page it resolved to /index.php/wiki.js, which is not the script. wiki.js no longer fails if the formatting help (drawer) or the save button is missing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
The Markdown formatting help (drawer) was only available when editing existing pages, although new pages use the same editor and need it just as much. wiki.js, which opens it, is now loaded for new pages as well. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
This was referenced Oct 2, 2026
Closed
… wiki.js The condition "edit or new" was written out in three places in index.php: for loading wiki.js, for showing the editor form and for showing the formatting help. They had already got out of step once (the help was missing on new pages). Use isEditorAction() for all of them, and test that the editor, the save button, wiki.js and the formatting help always appear together on every kind of page. As wiki.js is only loaded where the editor is, it can rely on the text area, the save button and the formatting help, so remove the guard around the save button. The title field only exists for new pages and stays optional. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS
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.
Summary
Fixes the issues found in a security review of w2wiki, in small commits (one per contiguous change), and adds an automated test suite with CI. The most serious issue allowed remote code execution without authentication (with the default
REQUIRE_PASSWORD=false) by renaming an uploaded image to.php.Critical / high
.htaccess) can't be touched..htaccessfiles forpages/(no direct access to page sources /.git),pages/images/(no script execution,nosniff, sandboxing CSP for SVG) andvendor/, no directory listings; nginx equivalents documented in INSTALL.md.h()helper used for all output of request values, page/file names and messages;{{image}}shorthand and heading anchors are escaped; link/image URLs are limited to safe schemes (nojavascript:/data:).enshrined/svg-sanitize(composer require,SVG_UPLOADS_ENABLED); the library is GPL-2.0-or-later, so it is deliberately not bundled (only a test dependency). SVGs are never passed to ImageMagick.Medium / low
preg_quotefor names, escaped replacement strings, plain substring search; non-greedy link rewriting (deleting/renaming a page no longer damages other links on the same line).password_hashsupport (legacy SHA-1 still accepted), constant-time comparison (no more "magic hash"0e…matches), session ID regenerated on login, HttpOnly/SameSite/(Secure) cookie, failed logins logged and delayed, login refused while the default passwordsecretis configured. A plainW2_PASSWORDalso works again (with an emptyW2_PASSWORD_HASHthe constant was defined twice and login could never succeed).api.phpnow requires login/IP check (sharedauth.php), no longer allows path traversal, and ignores hidden files.maxsizeclamped to 20-8192.PAGES_PATHshell-escaped for git.images/, with hidden or empty segments; renames never overwrite pages.A+BopenedA B,x%41yopenedxAy): fixed, old+links get a 301."${var}"string interpolation deprecated since PHP 8.2.Refactoring
Side-effect-free helper functions moved unchanged from
index.php/auth.phpintofunctions.php/auth_functions.php(so they can be unit tested); the class autoloader resolves files relative to its own folder;isCorrectPassword()takes the configured hash/password as optional parameters.Tests and CI
composer install && composer test(seetests/README.md); all suites run on GitHub Actions:api.php, main views; fails on logged PHP warnings/deprecationsphp:apache,.htaccessfiles) and nginx + php-fpm (rules from INSTALL.md) in Docker: no access to page sources/.git/vendor, scripts in uploads never executed, nosniff + sandboxing CSP for SVG, no directory listings, end-to-end uploadThe tests were checked against the vulnerable code on
main(they fail there, and the browser tests see the scripts run), and with protections deliberately removed (sanitizer,.htaccessfiles, regex quoting, ...). Manual test plan:TEST_PLAN.md(shared with this PR, not committed).Not covered by automated tests: git integration, ImageMagick paths (resize/convert/rotate), HTTPS
Securecookie flag, non-en/delocales, other web servers.Upgrade notes (behaviour changes)
W2_PASSWORDis stillsecret; set a password or aW2_PASSWORD_HASH(password_hashoutput recommended).post_max_sizealso give the 403 message.AllowOverride All(orAuthConfig,FileInfo,Options) for the new.htaccessfiles.$allowedIPsentries written as bare prefixes (e.g.192.168.1) no longer match; use192.168.1., a CIDR range or the full address.%20instead of+.Follow-ups
{{image}}references not updated on rename/delete./icons/…URLs, which Debian/Ubuntu's default Apache configuration shadows with its ownAlias /icons/(icons then return 404 unless that alias is removed).🤖 Generated with Claude Code
https://claude.ai/code/session_0153ydi3ZeUKJmfyPpTKoQfS