Skip to content

Commit 406b846

Browse files
authored
Merge pull request #330 from benmcollins/312-typ-allowlist
jwt: typ media-type helper + algorithm allowlist (RFC 8725)
2 parents da5c24c + 910e3e4 commit 406b846

7 files changed

Lines changed: 370 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,9 @@ if (CHECK_FOUND)
418418
# RFC 7797 unencoded / detached payload
419419
list (APPEND UNIT_TESTS jws_b64)
420420
421+
# RFC 8725 typ helper + algorithm allowlist
422+
list (APPEND UNIT_TESTS jwt_typ_alg)
423+
421424
# ML-DSA (FIPS 204 / RFC 9964). The test is a no-op unless the build
422425
# enabled WITH_ML_DSA against a capable backend.
423426
list (APPEND UNIT_TESTS jwt_mldsa)

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Standard | RFC
1616
``JWT`` | :page_facing_up: [RFC-7519](https://datatracker.ietf.org/doc/html/rfc7519) | JSON Web Token
1717
``JWK Thumbprint`` | :page_facing_up: [RFC-7638](https://datatracker.ietf.org/doc/html/rfc7638) / [RFC-9278](https://datatracker.ietf.org/doc/html/rfc9278) | JWK Thumbprint and Thumbprint URI
1818
``cnf`` | :page_facing_up: [RFC-7800](https://datatracker.ietf.org/doc/html/rfc7800) | Proof-of-Possession (confirmation) claim helpers
19+
``Unencoded Payload`` | :page_facing_up: [RFC-7797](https://datatracker.ietf.org/doc/html/rfc7797) | JWS unencoded (``b64=false``) and detached payloads
20+
``BCP 225`` | :page_facing_up: [RFC-8725](https://datatracker.ietf.org/doc/html/rfc8725) | JWT Best Current Practices (``typ`` check, algorithm allowlist)
1921

2022
> [!NOTE]
2123
> Throughout this documentation you will see links such as the ones

include/jwt.h

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,22 @@ int jwt_builder_setb64(jwt_builder_t *builder, int b64);
658658
JWT_EXPORT
659659
int jwt_builder_set_detached(jwt_builder_t *builder, int detached);
660660

661+
/**
662+
* @brief Set the token media type ("typ" header)
663+
*
664+
* A convenience setter for the ``"typ"`` header parameter, naming the token's
665+
* media type — e.g. ``"at+jwt"`` (RFC 9068), ``"dpop+jwt"``, ``"secevent+jwt"``.
666+
* Equivalent to setting the ``"typ"`` header directly. Pair it with
667+
* jwt_checker_expect_typ() on the verifying side.
668+
*
669+
* @param builder Pointer to a builder object
670+
* @param typ The media type string, or NULL to clear it
671+
* @return 0 on success, non-zero otherwise with error set in the builder
672+
* @since 3.6.0
673+
*/
674+
JWT_EXPORT
675+
int jwt_builder_settyp(jwt_builder_t *builder, const char *typ);
676+
661677
/**
662678
* @brief Set IssuedAt usage on builder
663679
*
@@ -985,6 +1001,42 @@ JWT_EXPORT
9851001
int jwt_checker_setkeyring(jwt_checker_t *checker, const jwk_set_t *keyring,
9861002
jwt_verify_policy_t policy);
9871003

1004+
/**
1005+
* @brief Require a specific token media type ("typ" header)
1006+
*
1007+
* When set, jwt_checker_verify() rejects a token whose ``"typ"`` header does not
1008+
* match @p typ. The comparison is case-insensitive and tolerates the optional
1009+
* ``application/`` prefix (RFC 6838), so ``expect_typ(c, "at+jwt")`` accepts both
1010+
* ``"at+jwt"`` and ``"application/at+jwt"``. This is the standardized
1011+
* cross-JWT-confusion defense (@rfc{8725} §3.11).
1012+
*
1013+
* @param checker Pointer to a checker object
1014+
* @param typ The required media type, or NULL to clear the requirement
1015+
* @return 0 on success, non-zero otherwise with error set in the checker
1016+
* @since 3.6.0
1017+
*/
1018+
JWT_EXPORT
1019+
int jwt_checker_expect_typ(jwt_checker_t *checker, const char *typ);
1020+
1021+
/**
1022+
* @brief Set an allowlist of acceptable algorithms
1023+
*
1024+
* Restricts the algorithms jwt_checker_verify() will accept to the given set,
1025+
* checked before any signature work (@rfc{8725}). Useful when verifying against
1026+
* a keyring (jwt_checker_setkeyring()) where several algorithms are acceptable,
1027+
* e.g. ``{JWT_ALG_RS256, JWT_ALG_ES256}``. A token whose ``"alg"`` is not in the
1028+
* set is rejected, which also blocks an ``alg:none`` downgrade. Passing @p n as
1029+
* 0 (or @p algs as NULL) clears the allowlist.
1030+
*
1031+
* @param checker Pointer to a checker object
1032+
* @param algs An array of acceptable ::jwt_alg_t values (copied)
1033+
* @param n The number of entries in @p algs
1034+
* @return 0 on success, non-zero otherwise with error set in the checker
1035+
* @since 3.6.0
1036+
*/
1037+
JWT_EXPORT
1038+
int jwt_checker_setalgs(jwt_checker_t *checker, const jwt_alg_t *algs, size_t n);
1039+
9881040
/**
9891041
* @brief Set a callback for generating tokens
9901042
*

libjwt/jwt-common.c

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ void FUNC(free)(jwt_common_t *__cmd)
5555
/* @rfc{7797} Free any raw payload (jwt_builder_setpayload()). */
5656
jwt_scrub_and_free(__cmd->c.payload_raw, __cmd->c.payload_raw_len);
5757

58+
/* @rfc{8725} Free the checker's typ expectation / algorithm allowlist. */
59+
jwt_freemem(__cmd->c.expected_typ);
60+
jwt_freemem(__cmd->c.alg_allowlist);
61+
5862
memset(__cmd, 0, sizeof(*__cmd));
5963

6064
jwt_freemem(__cmd);
@@ -663,6 +667,61 @@ int jwt_checker_setkeyring(jwt_checker_t *checker, const jwk_set_t *keyring,
663667
return 0;
664668
}
665669

670+
int jwt_checker_expect_typ(jwt_checker_t *checker, const char *typ)
671+
{
672+
char *copy = NULL;
673+
674+
if (checker == NULL)
675+
return 1;
676+
677+
if (typ != NULL) {
678+
size_t n = strlen(typ) + 1;
679+
680+
copy = jwt_malloc(n);
681+
if (copy == NULL) {
682+
jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
683+
return 1; // LCOV_EXCL_LINE
684+
}
685+
memcpy(copy, typ, n);
686+
}
687+
688+
jwt_freemem(checker->c.expected_typ);
689+
checker->c.expected_typ = copy;
690+
691+
return 0;
692+
}
693+
694+
int jwt_checker_setalgs(jwt_checker_t *checker, const jwt_alg_t *algs, size_t n)
695+
{
696+
jwt_alg_t *copy = NULL;
697+
size_t i;
698+
699+
if (checker == NULL)
700+
return 1;
701+
702+
if (algs != NULL && n > 0) {
703+
for (i = 0; i < n; i++) {
704+
if (algs[i] >= JWT_ALG_INVAL) {
705+
jwt_write_error(checker,
706+
"Invalid algorithm in allowlist");
707+
return 1;
708+
}
709+
}
710+
copy = jwt_malloc(n * sizeof(jwt_alg_t));
711+
if (copy == NULL) {
712+
jwt_write_error(checker, "Error allocating memory"); // LCOV_EXCL_LINE
713+
return 1; // LCOV_EXCL_LINE
714+
}
715+
memcpy(copy, algs, n * sizeof(jwt_alg_t));
716+
}
717+
718+
jwt_freemem(checker->c.alg_allowlist);
719+
checker->c.alg_allowlist = copy;
720+
checker->c.n_alg_allowlist = copy ? n : 0;
721+
722+
return 0;
723+
}
724+
666725
static const struct jwt_signature *checker_sig_at(const jwt_checker_t *checker,
667726
unsigned int index)
668727
{
@@ -766,6 +825,27 @@ int jwt_builder_set_detached(jwt_builder_t *builder, int detached)
766825
return 0;
767826
}
768827

828+
int jwt_builder_settyp(jwt_builder_t *builder, const char *typ)
829+
{
830+
jwt_json_t *v;
831+
832+
if (builder == NULL)
833+
return 1;
834+
835+
if (typ == NULL) {
836+
jwt_json_obj_del(builder->c.headers, "typ");
837+
return 0;
838+
}
839+
840+
v = jwt_json_create_str(typ);
841+
if (v == NULL || jwt_json_obj_set(builder->c.headers, "typ", v)) {
842+
jwt_write_error(builder, "Error setting \"typ\" header"); // LCOV_EXCL_LINE
843+
return 1; // LCOV_EXCL_LINE
844+
}
845+
846+
return 0;
847+
}
848+
769849
jwt_signature_t *jwt_builder_add_signature(jwt_builder_t *builder,
770850
jwt_alg_t alg, const jwk_item_t *key)
771851
{

libjwt/jwt-private.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ struct jwt_common {
122122
size_t payload_raw_len;
123123
int b64;
124124
int detached;
125+
126+
/* --- @rfc{8725} Checker ergonomics ---
127+
* @expected_typ: if set, the token's "typ" must match it (case-insensitive,
128+
* optional "application/" prefix). @alg_allowlist: if non-empty, the token's
129+
* "alg" must be one of these (checked before the signature). */
130+
char *expected_typ;
131+
jwt_alg_t *alg_allowlist;
132+
size_t n_alg_allowlist;
125133
};
126134

127135
struct jwt_builder {

libjwt/jwt-verify.c

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
#include <stdlib.h>
1010
#include <string.h>
11+
#include <strings.h>
1112
#include <stdio.h>
1213

1314
#include <jwt.h>
@@ -420,9 +421,58 @@ static jwt_claims_t __verify_jti(jwt_t *jwt)
420421
}
421422

422423
/* This is after parsing and possibly a user callback. */
424+
/* @rfc{8725,3.11} Compare a media type ignoring case and an optional
425+
* "application/" prefix (RFC 6838), so "at+jwt" matches "application/at+jwt". */
426+
static int jwt_typ_matches(const char *want, const char *got)
427+
{
428+
if (!strncasecmp(want, "application/", 12))
429+
want += 12;
430+
if (!strncasecmp(got, "application/", 12))
431+
got += 12;
432+
433+
return !strcasecmp(want, got);
434+
}
435+
436+
/* @rfc{8725} Does the parsed header (@jwt->headers) and algorithm (@jwt->alg)
437+
* satisfy the checker's "typ" expectation and algorithm allowlist? Returns 1 if
438+
* acceptable (or nothing is configured), 0 if it should be rejected. */
439+
static int jwt_typ_alg_ok(jwt_t *jwt)
440+
{
441+
jwt_checker_t *checker = jwt->checker;
442+
size_t i;
443+
444+
if (checker == NULL)
445+
return 1; // LCOV_EXCL_LINE
446+
447+
if (checker->c.expected_typ != NULL) {
448+
jwt_json_t *t = jwt_json_obj_get(jwt->headers, "typ");
449+
const char *got = (t && jwt_json_is_string(t))
450+
? jwt_json_str_val(t) : NULL;
451+
452+
if (got == NULL || !jwt_typ_matches(checker->c.expected_typ, got))
453+
return 0;
454+
}
455+
456+
if (checker->c.n_alg_allowlist > 0) {
457+
for (i = 0; i < checker->c.n_alg_allowlist; i++)
458+
if (checker->c.alg_allowlist[i] == jwt->alg)
459+
return 1;
460+
return 0;
461+
}
462+
463+
return 1;
464+
}
465+
423466
static int __verify_config_post(jwt_t *jwt, const jwt_config_t *config,
424467
unsigned int sig_len)
425468
{
469+
/* @rfc{8725} Enforce the typ expectation / algorithm allowlist first. */
470+
if (!jwt_typ_alg_ok(jwt)) {
471+
jwt_write_error(jwt,
472+
"Token rejected by \"typ\" or algorithm policy");
473+
return 1;
474+
}
475+
426476
/* Yes, we do this before checking a signature. @rfc{7797} An unencoded
427477
* (b64=false) payload is opaque, not JSON claims, so skip claim checks. */
428478
if (jwt->b64 && __verify_claims(jwt)) {
@@ -695,6 +745,13 @@ static int verify_entry(jwt_checker_t *checker, jwt_t *jwt,
695745
}
696746
}
697747

748+
/* @rfc{8725} A signature whose "typ"/alg does not meet the checker's
749+
* expectation is simply not accepted (skipped), so the policy decides. */
750+
if (!jwt_typ_alg_ok(jwt)) {
751+
jwt->headers = NULL;
752+
return 0;
753+
}
754+
698755
/* Seed the candidate: a kid-named keyring key (a binding assertion, no
699756
* fallback), or the checker's single key. A keyless keyring entry scans. */
700757
kid = json_str(s->protected, "kid");

0 commit comments

Comments
 (0)