News

WebCalendar v1.9.26: 55 Security Fixes From an AI-Assisted Audit

WebCalendar v1.9.26 is a security release. It fixes 55 confirmed vulnerabilities found in a full source audit of the codebase: 2 critical, 12 high, 25 medium and 16 low. Two of them can be exploited by a visitor who is not logged in. Everyone running WebCalendar should upgrade to v1.9.27, which includes all of the v1.9.26 fixes plus one follow-up fix described below.

The first half of this post is for administrators: what was fixed, how serious it is, and how to upgrade. The second half is for developers. It explains how the audit was done, using Claude Code and a security-audit skill, and how 55 findings went from report to merged fixes to published advisories in about two days.

What You Need to Do

  • Upgrade to v1.9.27. Every version before v1.9.26 is affected. Neither v1.9.26 nor v1.9.27 changes the database schema. If you are coming from an older release, back up your database and follow the Upgrade Guide as usual.
  • Remove or block the wizard/ directory once your install or upgrade is done. The most severe finding was in the installer, and nothing outside wizard/ needs it at runtime. If a later upgrade needs the wizard, WebCalendar will tell you to put it back.
  • Set SERVER_URL explicitly in Admin > Settings if it isn’t already set. One of the advisories covers a bug where WebCalendar could store a server URL taken from an attacker’s request.

Why v1.9.27 and not v1.9.26? One v1.9.26 fix closed a file-inclusion hole in the custom template code. It also stopped loading a site-wide external header, stylesheet or trailer that an administrator had set up outside templates/. v1.9.27, released the same day, restores that feature for the admin-level template only. It also adds an ALLOW_EXTERNAL_HEADER_PHP setting, off by default, for sites whose external header has to run as PHP. If you use an external header file with PHP in it, turn that setting on after upgrading.

The Advisories

The 55 findings are grouped by root cause into 14 GitHub security advisories, all published on October 23, 2026 and all fixed in v1.9.26. CVE IDs have been requested for each one, but none have been assigned yet. I’ll add them to this post when they are.

SeverityCVSSAdvisorySummary
Critical10.0GHSA-9g79-57rj-226hUnauthenticated installer API on an installed instance allows administrator creation and remote code execution
Critical9.8GHSA-q3xc-2fc9-2w2rPath traversal and local file inclusion allow unauthenticated file read and PHP code execution
Critical9.6GHSA-f7pp-v37v-6jf2Cross-account preference writes, admin-only setting override and MCP API token theft
High8.8GHSA-825c-jpqw-v3g5A non-admin can rewrite any user’s permissions and grant themselves admin
High7.6GHSA-pfpv-6q33-2cgfStored and reflected cross-site scripting in several places
High7.5GHSA-8cj4-774c-g3xhOther users can see private and confidential events, attachments and comments
High7.4GHSA-3jxv-xhr2-q7v6Authentication and session weaknesses, including a pass-the-hash remember-me cookie
High7.1GHSA-fjxv-c3c3-7v8gMissing ownership checks (IDOR) let users modify or delete other users’ events, reports, views and layers
Medium6.5GHSA-6f5j-9h4w-vgq9An unauthenticated request can permanently poison SERVER_URL
Medium6.5GHSA-v9xq-q6p4-w582Public-access and account-lifecycle bypasses (moderation, CAPTCHA, registration, open redirect)
Medium5.4GHSA-fpvh-xh47-4qgmMCP write tools bypass the “Edit Event” permission and read-only mode
Medium5.3GHSA-p788-p4g2-xfj7The SMTP STARTTLS setting was not applied, so mail and SMTP credentials could be sent in cleartext
Medium4.3GHSA-2rfx-ff42-97rfServer-side request forgery in the remote-calendar URL check via IPv4-mapped IPv6 addresses
Medium4.3GHSA-96pp-cqhg-hjcmCross-site request forgery via state-changing GET requests

Two earlier advisories were fixed in v1.9.25 on October 5: an unauthenticated local file inclusion in js_cacher.php (GHSA-vf78-25w4-9676, high), reported by dutchypoo, and a related reflected XSS (GHSA-gf7j-4j4v-x684, medium). Thank you to dutchypoo for the report.

No database schema changes were needed for any of these fixes.


How the Audit Was Done

WebCalendar is more than 25 years old. Large parts of it were written for PHP 3 and 4, back when request handling followed very different conventions. I had fixed security bugs as they were reported over the years, but no one had ever gone through the whole codebase systematically. This time I had Claude Code do it, using a security-audit skill that runs a complete audit workflow in several phases.

The process had four steps:

  1. Audit. Claude ran the security-audit skill against every git-tracked file, excluding third-party code.
  2. Report and plan. Claude wrote up the findings and a remediation plan that grouped them by root cause.
  3. Fix. Claude worked through the plan one finding at a time. Each fix came with a test, and the fixes went into pull requests that I reviewed and merged.
  4. Disclose. We released v1.9.26, published the GitHub advisories and requested CVEs.

Step 1: Hunting, then validating

The audit split the codebase into 42 units of work and sent hunter agents through them in four waves. Between waves, a coverage critic looked for parts of the code no one had examined and assigned new work. After the fourth pass, the critic found no further gaps.

Hunters only propose vulnerabilities. Each candidate then went to a separate validator, whose job was to reproduce it or reject it. There were 65 validator workspaces, and about 106 agent workspaces in the whole run. A finding counted as confirmed only if it had a concrete observed result. For example: “On an installed instance, an anonymous POST created a second admin,” or “Bob’s export returned Alice’s private and confidential events in full.” Of the 63 unique candidates, 55 were confirmed, 6 were marked as needing more validation and 2 were rejected.

None of this touched a real calendar. My WebCalendar checkout is also a live installation, so the audit ran under strict limits. Every local check ran inside a bubblewrap sandbox with no network access, an empty environment, the source mounted read-only, a dummy settings.php and throwaway SQLite databases filled with made-up users like Alice, Bob and Mallory. CPU, memory and run time were capped as well. Nothing ran against the live deployment.

A useful calibration point

The audit ran against a commit from September 28. That was before the js_cacher.php fix that shipped in v1.9.25 after dutchypoo’s report. The audit independently found the same bug and listed it as critical finding #1, with the same traversal through the . and .. entries of a directory listing. An outside researcher found it first, and that credit stands. Still, having the audit re-find a real, externally reported bug in code it had never been told about gave me more confidence in the other 54. The remediation then hardened js_cacher.php beyond the v1.9.25 fix by resolving paths with realpath() and requiring them to stay inside the expected directory.

Step 2: Fix the pattern, not just the instance

Fifty-five separate findings sounds like fifty-five separate bugs. Many were actually the same mistake in different files. The remediation plan grouped them into root-cause families so that each shared fix landed once, and it called out a few patterns to search for across the whole tree:

  • A permission check that returns the wrong kind of value. access_user_calendar() returns a permission bitmask, but several handlers tested it as if it returned 'Y' or 'N'. In PHP, the string 'N' is truthy, so “no” meant “yes.”
  • assert() used as a security check. Assertions are usually disabled in production PHP, which made one write guard on the system template a no-op.
  • Checking one thing and acting on another. A handler would authorize against the user value in POST and then act on the user value in GET, or check a delegation flag the client controlled. The fix was to resolve the target once, authorize that exact value, and act on it.
  • Special principals. Public access, nonuser calendars and similar accounts slipped through checks written with real users in mind.
  • Escaping in the wrong layer. Much of the output relied on a filter that blocked a list of HTML tag names. That doesn’t stop attributes like onload=. The fix was to escape user data at the point of output.

The plan also set the ground rules for the fixes. Make the smallest fix that works, at the last point where a trusted decision is made. Write a regression test first that fails on the current code and passes after the fix. Never edit the live tree; all work happens in a separate git worktree. Run the full CI check locally before every pull request.

Step 3: One finding at a time

Claude worked through the plan in order of severity, starting with the criticals. Each finding became its own commit with its own test. The commit messages name the audit finding they close, so every change can be traced back to the report. The work landed in three pull requests:

  • #801: the main remediation, with 28 commits across 66 files.
  • #802: hardening for the leads that still needed validation (a reflected XSS, an RSS CDATA breakout, pinning a release GitHub Action to a commit SHA, an LDAP cookie issue).
  • #803: two leftovers that had been deferred. The IMAP and NIS remember-me check now fails closed, and DB2/ODBC escaping uses standard SQL quote-doubling instead of addslashes().

I reviewed and merged every pull request. Altogether the v1.9.26 and v1.9.27 work added about 1,900 lines of tests in 33 test files. The fix commits span about six hours, from the evening of October 22 to just after midnight. v1.9.26 was released early on October 23.

Step 4: Disclosure

Publishing 55 separate advisories would have been unreadable for administrators, so the findings were grouped by root cause into the 14 advisories in the table above. Each one lists its preconditions, its CWE classes and a CVSS score. Claude drafted them and I edited them before they were published. CVE IDs have been requested and are still pending.

Old Code and New Code

It would be convenient to blame all of this on code from 2003. Most of it was, but not all of it. The audit traced each of its 63 records to the commit that first introduced the vulnerable line. 46 of them go back to code written between 2001 and 2008. Ten trace to code written in 2026, and that includes the worst finding of the whole audit: the installer API with no authorization check, rated CVSS 10.0, which was part of this year’s rewrite of the installation wizard. That’s my code, and it’s recent.

Code age didn’t predict safety. A rewrite fixes the problems you are looking for, but it can introduce new ones you aren’t. An installer is especially easy to get wrong in this way. It is written for a moment when nothing is configured yet, and then it stays on the server long after that moment has passed. That’s why the advice above is to remove or block wizard/ after installing, even on v1.9.27.

By the Numbers

  • 42 units of work, 4 hunting waves and 4 coverage-critic passes
  • 63 unique candidates, each validated independently: 55 confirmed, 6 needing more validation, 2 rejected
  • Confirmed findings: 2 critical, 12 high, 25 medium, 16 low
  • About 106 agent workspaces in the audit run
  • 3 pull requests, about 1,900 lines of new tests
  • 14 advisories published October 23, 2026; CVE IDs pending
  • Audit to release: October 22 to October 23

If you find a security problem in WebCalendar, please report it privately by email, as described in the project’s security policy, rather than in a public issue. For everything else, see the earlier v1.9.24 release notes or the guide to installing WebCalendar with Docker Compose.

Leave a Reply

Your email address will not be published. Required fields are marked *