Skip to content

mod_autht_jwt: Fix authentication bypass via claim-name prefix matching (BZ 69946) - #950

Open
arturobernalg wants to merge 1 commit into
apache:trunkfrom
arturobernalg:fix/bz69946-jwt-claim-prefix-match
Open

arturobernalg wants to merge 1 commit into
apache:trunkfrom
arturobernalg:fix/bz69946-jwt-claim-prefix-match

Conversation

@arturobernalg

Copy link
Copy Markdown
Member

check_token() identified the aud, sub, exp and nbf claims with:

strncmp("aud", kv->k->value.string.p, kv->k->value.string.len)

strncmp(a, b, n) only compares the first n bytes, and n here is the actual claim-name length, not the length of the literal. A claim whose name is a proper prefix of "aud" (e.g. "a" or "au") therefore satisfies this check and is treated as the real 'aud' claim. A JWT that omits 'aud' entirely but carries such a short claim set to the expected AuthName value then passes the audience check that exists specifically to confirm the token was issued for this location -- an authentication bypass. The same pattern affected 'sub', 'exp' and 'nbf'.

Fix: require the claim-name length to match the literal's length before comparing, so only an exact match counts.

Bugzilla: https://bz.apache.org/bugzilla/show_bug.cgi?id=69946 (filed 2026-02-04, public, no existing patch or PR)

Verified against a real, running httpd + mod_autht_jwt (not just static analysis): built apr-util 1.7 from source (github.com/apache/apr-util, with OpenSSL crypto support) since this environment's system apr-util was too old for the module, then built and ran httpd with AuthType bearer / AuthBearerProvider jwt against it. With the fix applied:

  • A JWT with no aud claim but a au claim set to the AuthName value (the bypass) is correctly rejected: log shows "AH10444: ... 'aud' value was missing and did not match AuthName", identical to the case where aud is absent entirely.
  • Same result for a single-character a claim.
  • A JWT with no audience-like claim at all is correctly rejected (negative control).

Added a pytest regression test (test_mod_autht_jwt.py) building real HS256 JWTs by hand. I could not run the full pytest suite in my sandbox (system apr-util there is 1.6.5, below the 1.7 the module requires, so it compiles to a no-op stub there), but I did verify the underlying behavior manually against a real, apr-util-1.7-linked build as described above. Please confirm the pytest file itself passes in CI before merge.


  • This PR does not report or address a security vulnerability
    (see SECURITY.md and
    https://www.apache.org/security/#reporting-a-vulnerability).
    This fixes BZ 69946, already public (filed 2026-02-04, listed on the
    public bugs@ mailing list and the public bug-health report) for ~9
    months with no patch; not a new disclosure.
  • Code follows the httpd style guide.
  • New log messages (level debug or higher) use an empty APLOGNO() tag;
    numbers are assigned by a committer at merge time (see docs/log-message-tags/README).
    N/A -- no new log messages in this change.
  • If the change is user-visible, a changes-entries/*.txt file is included,
    following the template in README.CHANGES (not needed otherwise).
  • Test cases based on pyhttpd are included where appropriate, in the
    test/modules/xxx directory matching the modules/xxx module source,
    or test/modules/core for core server changes.

https://claude.ai/code/session_01BrsHTcwUro4KV5Fp5hv8XM

arturobernalg added a commit to arturobernalg/httpd that referenced this pull request Nov 5, 2026
…ng (BZ 69946)

check_token() identified the 'aud', 'sub', 'exp' and 'nbf' claims with:

    strncmp("aud", kv->k->value.string.p, kv->k->value.string.len)

strncmp(a, b, n) only compares the first n bytes, and n here is the
actual claim-name length, not the length of the literal. A claim
whose name is a proper prefix of "aud" (e.g. "a" or "au") therefore
satisfies this check and is treated as the real 'aud' claim. A JWT
that omits 'aud' entirely but carries such a short claim set to the
expected AuthName value then passes the audience check that exists
specifically to confirm the token was issued for this location --
an authentication bypass. The same pattern affected 'sub', 'exp'
and 'nbf'.

Fix: require the claim-name length to match the literal's length
before comparing, so only an exact match counts.

Confirmed with a standalone C harness mirroring the exact comparison
(strncmp("aud", "au", 2) == 0 before the fix; correctly rejected
after). Confirmed via direct code reading that the bug has been
present since the module's creation in d8246a1 (April 2023) and
no later commit touches these lines.

Verified against a real, running httpd + mod_autht_jwt (built apr-util
1.7 from source with OpenSSL crypto support, since the module needs
apr_jose which requires apr-util >= 1.7). With the fix applied, a JWT
with no 'aud' claim but an 'au' claim set to the AuthName value (the
bypass) is rejected with the same AH10444 'aud value was missing'
message as a token with no aud-like claim at all; same result for a
single-character 'a' claim.

Added a pytest regression test (test_mod_autht_jwt.py) building real
HS256 JWTs by hand. Two test-infrastructure bugs found and fixed along
the way, independent of the production fix above:

- AuthtJwtVerify reads its secret file while parsing the config, before
  httpd starts; writing it from inside each test function (as the first
  version of this test did) runs too late. Fixed by writing it once,
  centrally, the same way TestConfig already generates the SSL CA
  (apache_pytest/config.py).

- modules/aaa/mod_autht_jwt.c can be present as a loadable module
  (satisfying <IfModule>) while still being a non-functional stub that
  registers neither AuthtJwtVerify nor the 'jwt' Autht provider, on any
  build whose apr-util lacks apr_jose (a separate, pre-existing bug,
  BZ 70276/70277). Without a guard, the new autht_jwt.conf.in fragment
  broke httpd startup -- and the entire shared pytest_suite session, not
  just these four tests -- on exactly such a CI leg (confirmed via the
  actual failing GitHub Actions log for PR apache#950, both 'Python
  pytest_suite, motorz MPM' and the 64-bit ARM job). Fixed by also
  guarding on <IfDirective AuthtJwtVerify>, which I verified directly
  with httpd -t against both a stub build (skips the block, Syntax OK)
  and a real apr-util-1.7-linked build (still applies the block).

Claude-Session: https://claude.ai/code/session_01BrsHTcwUro4KV5Fp5hv8XM
@arturobernalg
arturobernalg force-pushed the fix/bz69946-jwt-claim-prefix-match branch from 2671cf8 to 0729b86 Compare November 5, 2026 06:43
arturobernalg added a commit to arturobernalg/httpd that referenced this pull request Nov 5, 2026
…ng (BZ 69946)

check_token() identified the 'aud', 'sub', 'exp' and 'nbf' claims with:

    strncmp("aud", kv->k->value.string.p, kv->k->value.string.len)

strncmp(a, b, n) only compares the first n bytes, and n here is the
actual claim-name length, not the length of the literal. A claim
whose name is a proper prefix of "aud" (e.g. "a" or "au") therefore
satisfies this check and is treated as the real 'aud' claim. A JWT
that omits 'aud' entirely but carries such a short claim set to the
expected AuthName value then passes the audience check that exists
specifically to confirm the token was issued for this location --
an authentication bypass. The same pattern affected 'sub', 'exp'
and 'nbf'.

Fix: require the claim-name length to match the literal's length
before comparing, so only an exact match counts.

Confirmed with a standalone C harness mirroring the exact comparison
(strncmp("aud", "au", 2) == 0 before the fix; correctly rejected
after). Confirmed via direct code reading that the bug has been
present since the module's creation in d8246a1 (April 2023) and
no later commit touches these lines.

Verified against a real, running httpd + mod_autht_jwt (built apr-util
1.7 from source with OpenSSL crypto support, since the module needs
apr_jose which requires apr-util >= 1.7). With the fix applied, a JWT
with no 'aud' claim but an 'au' claim set to the AuthName value (the
bypass) is rejected with the same AH10444 'aud value was missing'
message as a token with no aud-like claim at all; same result for a
single-character 'a' claim.

Added a pytest regression test (test_mod_autht_jwt.py) building real
HS256 JWTs by hand. Two test-infrastructure bugs found and fixed along
the way, independent of the production fix above:

- AuthtJwtVerify reads its secret file while parsing the config, before
  httpd starts; writing it from inside each test function (as the first
  version of this test did) runs too late. Fixed by writing it once,
  centrally, the same way TestConfig already generates the SSL CA
  (apache_pytest/config.py).

- modules/aaa/mod_autht_jwt.c can be present as a loadable module
  (satisfying <IfModule>) while still being a non-functional stub that
  registers neither AuthtJwtVerify nor the 'jwt' Autht provider, on any
  build whose apr-util lacks apr_jose (a separate, pre-existing bug,
  BZ 70276/70277). Without a guard, the new autht_jwt.conf.in fragment
  broke httpd startup -- and the entire shared pytest_suite session, not
  just these four tests -- on exactly such a CI leg (confirmed via the
  actual failing GitHub Actions log for PR apache#950, both 'Python
  pytest_suite, motorz MPM' and the 64-bit ARM job). Fixed by also
  guarding on <IfDirective AuthtJwtVerify>, which I verified directly
  with httpd -t against both a stub build (skips the block, Syntax OK)
  and a real apr-util-1.7-linked build (still applies the block).

Claude-Session: https://claude.ai/code/session_01BrsHTcwUro4KV5Fp5hv8XM
@arturobernalg
arturobernalg force-pushed the fix/bz69946-jwt-claim-prefix-match branch from 0729b86 to f38954f Compare November 5, 2026 07:45
…ng (BZ 69946)

check_token() identified the 'aud', 'sub', 'exp' and 'nbf' claims with:

    strncmp("aud", kv->k->value.string.p, kv->k->value.string.len)

strncmp(a, b, n) only compares the first n bytes, and n here is the
actual claim-name length, not the length of the literal. A claim
whose name is a proper prefix of "aud" (e.g. "a" or "au") therefore
satisfies this check and is treated as the real 'aud' claim. A JWT
that omits 'aud' entirely but carries such a short claim set to the
expected AuthName value then passes the audience check that exists
specifically to confirm the token was issued for this location --
an authentication bypass. The same pattern affected 'sub', 'exp'
and 'nbf'.

Fix: require the claim-name length to match the literal's length
before comparing, so only an exact match counts.

Confirmed with a standalone C harness mirroring the exact comparison
(strncmp("aud", "au", 2) == 0 before the fix; correctly rejected
after). Confirmed via direct code reading that the bug has been
present since the module's creation in d8246a1 (April 2023) and
no later commit touches these lines.

Verified against a real, running httpd + mod_autht_jwt (built apr-util
1.7 from source with OpenSSL crypto support, since the module needs
apr_jose which requires apr-util >= 1.7). With the fix applied, a JWT
with no 'aud' claim but an 'au' claim set to the AuthName value (the
bypass) is rejected with the same AH10444 'aud value was missing'
message as a token with no aud-like claim at all; same result for a
single-character 'a' claim.

Added a pytest regression test (test_mod_autht_jwt.py) building real
HS256 JWTs by hand. Two test-infrastructure bugs found and fixed along
the way, independent of the production fix above:

- AuthtJwtVerify reads its secret file while parsing the config, before
  httpd starts; writing it from inside each test function (as the first
  version of this test did) runs too late. Fixed by writing it once,
  centrally, the same way TestConfig already generates the SSL CA
  (apache_pytest/config.py).

- modules/aaa/mod_autht_jwt.c can be present as a loadable module
  (satisfying <IfModule>) while still being a non-functional stub that
  registers neither AuthtJwtVerify nor the 'jwt' Autht provider, on any
  build whose apr-util lacks apr_jose (a separate, pre-existing bug,
  BZ 70276/70277). Without a guard, the new autht_jwt.conf.in fragment
  broke httpd startup -- and the entire shared pytest_suite session, not
  just these four tests -- on exactly such a CI leg (confirmed via the
  actual failing GitHub Actions log for PR apache#950, both 'Python
  pytest_suite, motorz MPM' and the 64-bit ARM job). Fixed by also
  guarding on <IfDirective AuthtJwtVerify>, which I verified directly
  with httpd -t against both a stub build (skips the block, Syntax OK)
  and a real apr-util-1.7-linked build (still applies the block).

Claude-Session: https://claude.ai/code/session_01BrsHTcwUro4KV5Fp5hv8XM
@arturobernalg
arturobernalg force-pushed the fix/bz69946-jwt-claim-prefix-match branch from f38954f to aae688d Compare November 5, 2026 08:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant