//nefariousplan

CVE-2026-39339: ChurchCRM's Auth Check Asks Whether "api/public" Is in the URI. The Bypass Writes It in the Query.

pattern

cve

proof of concept

ChurchCRM's API authentication middleware decides whether to skip auth by asking whether the substring api/public appears anywhere in the request URI. The path. The query string. The fragment. CVE-2026-39339 is what happens when an attacker writes the gate's allow-string into the field the gate didn't know it was reading.

GET /api/persons/latest?bypass=api/public returns the ten most recently added church members joined to their family rows. No API key. No session cookie. The middleware never asks.

The check is on the full URI, not the path

The pre-patch middleware is, on the line that matters:

if (!str_contains($request->getUri(), 'api/public')) {

$request->getUri() returns a Psr\Http\Message\UriInterface, the PSR-7 URI value object. str_contains is a string function. PHP coerces the object to a string by calling __toString(), which the PSR-7 specification defines to return the full URI: [scheme:][//authority][path][?query][#fragment]. The middleware's "is this a public route" predicate matches against that whole flattened string, not against the path the developer presumably had in mind.

An attacker who needs the predicate to return true writes the substring api/public into a part of the URI they control. The path is the part the router will dispatch on, so they cannot put /api/public there without changing the endpoint they are trying to reach. The query string is the part the router does not read until the auth decision has already run. They put it there:

GET /api/persons/latest?bypass=api/public

Stringified URI: http://target/api/persons/latest?bypass=api/public. str_contains(..., 'api/public') returns true. The middleware skips its API key check, skips its session check, and dispatches the request.

The advisory's PoC enumerates four other syntaxes that work for the same reason: ?test=api/public, ?hack=api/public, ?exploit=api/public, and #api/public. They are not five different bugs. They are five places __toString() puts the substring.

The structurally correct helper is one branch down

The middleware does not lack a structurally safe path-segment check. It defines one privately, in the same class:

private function isPath(Request $request, $pathPart)
{
    $pathAry = explode('/', $request->getUri()->getPath());
    if (!empty($pathAry) && $pathAry[0] === $pathPart) {
        return true;
    }
    return false;
}

Note $request->getUri()->getPath(): the path component, explicitly extracted. The helper splits on / and tests whether a specific segment is present. It is exactly the check the public-API skip needed.

The middleware calls isPath nine lines below the buggy line, on the same request, in the same if/elseif chain:

if (!str_contains($request->getUri(), 'api/public')) {
    $apiKey = $request->getHeader('x-api-key');
    if (!empty($apiKey)) {
        // API key validation
    } elseif (AuthenticationManager::validateUserSessionIsActive(!$this->isPath($request, 'background'))) {
        // session validation
    } else {
        return $response->withStatus(401, gettext('No logged in user'));
    }
}

The "background" branch uses the structurally correct helper. The "api/public" branch, written in the same method, by the same author, on the same request, does not. The two idioms are visually adjacent. They are not equivalent.

Every endpoint the router can dispatch is in scope

The Slim 4 application is wired in src/api/index.php with one line that gates the entire API:

$app->add(AuthMiddleware::class);

Every route that follows, every group, every per-route middleware, runs after AuthMiddleware decides. The bypass is therefore not endpoint-specific. The advisory enumerates six confirmed targets: /api/persons/latest (member dump), /api/persons/roles (family relationships), /api/persons/duplicate/emails (email enumeration), /api/geocoder/address (POST), /api/background/timerjobs (POST, runs background tasks), /api/calendar/{id} (DELETE). Whatever the router can dispatch is reachable with ?bypass=api/public appended.

The getLatestPersons handler is illustrative because it is an unauthenticated, unpaginated dump:

function getLatestPersons(Request $request, Response $response, array $args): Response
{
    $people = PersonQuery::create()
        ->leftJoinWithFamily()
        ->orderByDateEntered('DESC')
        ->limit(10)
        ->find();
    return SlimUtils::renderJSON($response, buildFormattedPersonList($people));
}

Ten records per request: PersonId, FirstName, LastName, FormattedName, Email, FamilyId, FamilyName, FamilyRole, Classification, Age, IsChild. The handler runs no audit logging on unauthenticated reads because the middleware that would have flagged the read fired the skip path. The reporter's PoC includes a sample response containing a Church Admin row with Created: 08/25/2004, harvested with ?test=api/public against a default install. The bypass works as long as the database has rows.

Who runs this and what leaks

ChurchCRM is self-hosted PHP/MySQL congregation-management software. The deployment is a small church's IT volunteer or a part-time contractor pointing a LAMP stack at a domain on Bluehost or DreamHost or whatever shared host the parish council picked in 2014. The data set is the parish: every member's full name and family role, every child's age, every household's address through the same /api/geocoder/address endpoint the bypass reaches, every donation record through related person endpoints, every pastoral note attached to a member through the notes endpoints, every email address ChurchCRM has stored for outreach. The Shodan footprint listed in the Nuclei template (http.title:"churchcrm") returns several thousand instances on the public internet at any given time, almost none of them maintained by anyone whose job description includes "monitor your security advisories."

ChurchCRM's auth surface has been generating CVEs at a steady cadence: GHSA-5w59-32c8-933v in April 2026 (object-level authorization missing on /api/person/{id}, patched by adding redirectHomeIfFalse checks the legacy interface had always done), GHSA-cwp8-rm8g-q5c9 in April 2026 (2FA and lockout bypass in API login), GHSA-jx5r-p82p-2p8m on May 1 2026 (CSRF on three legacy delete endpoints in 7.2.2), and this one. Four advisories in two months, all touching the same authentication-and-authorization layer. CVE-2026-39339 is the most embarrassing of the four because the helper that would have prevented it was already in the file. The other three required adding code that wasn't there. This one required calling code that was.

The fix took two commits, ten minutes apart

The fix commit, 307faa95c on October 4, 2025, replaced one expression with another:

-if (!str_contains($request->getUri(), 'api/public')) {
+if (!str_contains($request->getUri()->getPath(), 'api/public')) {

Adding ->getPath() closes the query-string bypass. It does not close the substring-vs-segment problem. str_contains against the path string still matches /anything/api/public/anything, and any future application route the maintainer adds without remembering this check, where api/public appears as an internal segment, would silently inherit the skip.

Ten minutes later, commit 7960ca1c4 (with a Copilot co-author tag) replaced str_contains with str_starts_with and added a leading slash:

-if (!str_contains($request->getUri()->getPath(), 'api/public')) {
+if (!str_starts_with($request->getUri()->getPath(), '/api/public')) {

A subsequent February 2026 commit handles subdirectory installations:

$publicApiPath = SystemURLs::getRootPath() . '/api/public';
if (!str_starts_with($request->getUri()->getPath(), $publicApiPath)) {

Three commits to converge on a path-prefix check. The file's existing isPath() helper, which already extracts the path with getPath() and compares structurally, was untouched through every revision. The maintainer kept fixing the wrong idiom rather than reach for the right one, four lines below.

The 2017 author wrote getPath. The 2023 modernizer removed it.

The original public-API check, committed December 24, 2017, was structural:

if (!$this->isPublic($request->getUri()->getPath())) {
    // ...
}

private function isPublic($path) {
    $pathAry = explode("/", $path);
    if (!empty($path) && $pathAry[0] === "public") {
        return true;
    }
}

Note $request->getUri()->getPath(). The 2017 author understood that the URI is an object, that the path is a field, and that the field is what you compare. The helper was renamed to isPath and generalized over time, but the idiom held: extract the path, explode on /, compare a segment.

November 24, 2023, commit 8c4d9062a, message matching Slim4 geURI (sic). The Slim 3 to Slim 4 migration. The relevant change is one line:

-if (!$this->isPath($request, 'public')) {
+if (!str_contains($request->getUri(),'api/public')) {

The pre-migration line called $this->isPath($request, 'public'), the same helper the file still defines today. The Slim 4 migration replaced it with str_contains against the URI object's stringification. The isPath helper kept living in the file because the background branch still needed it. Two adjacent idioms in the same method. Only one survived the migration as a structural check; the other became a substring match against __toString().

The fix landed in main on October 4, 2025, twenty-two months after the bug shipped. The first release containing the fix was 7.0.0 on February 25, 2026, four months after the silent fix landed. The advisory was published April 5, 2026. The Nuclei template appeared on May 4, 2026, the day this post was written.

The advisory's "Patched in 7.1.0" understates the timeline. The fix had been in production releases for thirty-nine days when the advisory went out, and in main for six months. The releases between October 2025 and April 2026 (7.0.0, 7.0.1, 7.0.2, 7.0.3, 7.0.4, 7.0.5) all carry the patch silently. Operators tracking advisories, not commits, had no signal that anything had changed in the auth middleware until April 5.

Predicate stringifies the object

The bug class is broader than ChurchCRM. PSR-7's UriInterface::__toString is documented to return the full URI; Java's java.net.URI.toString does the same; .NET's Uri.ToString does the same; Go's net/url.URL.String does the same. Every URL value type in every framework stringifies to the full URL because the string form is for logging and for reconstruction, not for security predicates. A check that calls contains, startsWith, or equals against a stringified URL reads more than the developer thinks it reads. A check that calls .path first reads the field by name.

The pattern is broader than URLs. Any structured value with a __toString (a request object whose stringification serializes the headers, a JWT whose stringification dumps the claims, an X.509 whose stringification flattens the distinguished name) accepts substring tests against the whole flattened object. Every field of that object the attacker controls is in the haystack, including fields the developer never thought were in the haystack. The check is named after the field the developer means to test. The implementation is named after the object that contains the field. The bypass is the difference.

This is predicate-stringifies-the-object. ChurchCRM's instance is the simplest available illustration: three keystrokes between the bug (getUri()) and the fix (getUri()->getPath()), with the structurally correct alternative defined in the same class and called from the same method. The field-by-field scan is the defender's work. Any place a security predicate uses a string operator against an object that has more than one field worth reading, the bypass is the developer's narrowest interpretation of what they wrote.

PoC: projectdiscovery/nuclei-templates

The 2017 author wrote ->getPath(). The 2023 modernizer removed it. The 2025 patch put it back.