From e07d79457cb3b4e8de89009071e447f799dd6b67 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 3 Aug 2026 20:56:23 -0300 Subject: [PATCH 1/4] feat(badges): accept URL-safe base64 (base64url) badge artifacts Badge QR artifacts travel base64-encoded as a URL path segment (GET /summits/{id}/badge/{artifact}/validate). The standard alphabet includes '/', and Laravel rawurldecodes the path before route matching (UriValidator) with {badge} compiled to [^/]+ - so any artifact whose wrapper contains '/' can never match the route and 404s with the HTML error page (ClickUp 86bb7wfgw). Accepting the RFC 4648 section 5 URL-safe alphabet lets callers keep the artifact in the path with no percent-encoding tricks. - looksLikeBase64: widen the alphabet to also accept '-' and '_' - tryBase64Decode: normalize (strtr '-_' -> '+/') before the strict base64_decode, which rejects the URL-safe alphabet Additive and backwards-compatible: standard base64 decodes byte identical; the sole caller is SummitAttendeeBadge::decodeQRCodeFor, so badge-scans POST, checkin and validateBadge gain it uniformly and existing clients are unaffected. Co-Authored-By: Claude --- app/Utils/Base64.php | 8 +++--- tests/Base64Test.php | 63 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/Base64Test.php diff --git a/app/Utils/Base64.php b/app/Utils/Base64.php index 2a742e170..3830aeb5b 100644 --- a/app/Utils/Base64.php +++ b/app/Utils/Base64.php @@ -17,9 +17,9 @@ final class Base64 public static function looksLikeBase64(string $s): bool { if ($s === '') return false; - // Solo alfabeto base64 y '=' de padding - if (!preg_match('#^[A-Za-z0-9+/]*={0,2}$#', $s)) return false; - // Longitud múltiplo de 4 (permitimos sin padding, lo añadimos abajo) + // Standard base64 alphabet (RFC 4648 §4) or URL-safe (§5: '-' and '_'), plus '=' padding + if (!preg_match('#^[A-Za-z0-9+/_-]*={0,2}$#', $s)) return false; + // Length multiple of 4 (padding may be omitted; it gets added below) return (strlen($s) % 4) === 0 || (strlen($s) % 4) === 2 || (strlen($s) % 4) === 3; } @@ -31,6 +31,8 @@ public static function padBase64(string $s): string public static function tryBase64Decode(string $s): ?string { + // strict base64_decode rejects the URL-safe alphabet: normalize it first + $s = strtr($s, '-_', '+/'); $padded = self::padBase64($s); $decoded = base64_decode($padded, true); return ($decoded === false) ? null : $decoded; diff --git a/tests/Base64Test.php b/tests/Base64Test.php new file mode 100644 index 000000000..257116577 --- /dev/null +++ b/tests/Base64Test.php @@ -0,0 +1,63 @@ +assertTrue(Base64::looksLikeBase64("+/+/")); + $this->assertSame("\xfb\xff\xbf", Base64::tryBase64Decode("+/+/")); + } + + public function testUrlSafeAlphabetIsAccepted() + { + // same payload as "+/+/", url-safe spelling + $this->assertTrue(Base64::looksLikeBase64("-_-_")); + $this->assertSame("\xfb\xff\xbf", Base64::tryBase64Decode("-_-_")); + } + + public function testUrlSafeAndStandardSpellingsDecodeToTheSameBytes() + { + $standard = Base64::tryBase64Decode("QUFB/QkJC+Q0PT0="); + $urlSafe = Base64::tryBase64Decode("QUFB_QkJC-Q0PT0="); + $this->assertNotNull($standard); + $this->assertSame($standard, $urlSafe); + } + + public function testUrlSafeWithoutPaddingIsPadded() + { + // "-_" = indices 62,63 = 11111011 = byte FB after padding to "-_==" + $this->assertTrue(Base64::looksLikeBase64("-_")); + $this->assertSame("\xfb", Base64::tryBase64Decode("-_")); + } + + public function testNonBase64InputIsRejected() + { + $this->assertFalse(Base64::looksLikeBase64("BADGE_X|123|a@b.com|Ada Lovelace")); + $this->assertFalse(Base64::looksLikeBase64("")); + $this->assertNull(Base64::tryBase64Decode("!!!")); + } +} From d8b24dbc19472ec77b318f5156e26b6d8dad7a5a Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 3 Aug 2026 21:27:04 -0300 Subject: [PATCH 2/4] fix(badges): reject malformed base64 padding in looksLikeBase64 Per CodeRabbit review on PR #576: the sniff accepted padding-only and partially padded inputs ('==', 'A=', 'QUFB==') and silently repaired under-padded ones ('QQ=' decoded as 'QQ=='). Padding may now be omitted entirely (padBase64 adds it) or must be exactly what the unpadded data length requires (RFC 4648); the strict decode already rejected these one step later, so caller-visible behavior only changes for the silently repaired case, which is now rejected. Co-Authored-By: Claude --- app/Utils/Base64.php | 9 +++++++-- tests/Base64Test.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/Utils/Base64.php b/app/Utils/Base64.php index 3830aeb5b..e1de5ba29 100644 --- a/app/Utils/Base64.php +++ b/app/Utils/Base64.php @@ -19,8 +19,13 @@ public static function looksLikeBase64(string $s): bool if ($s === '') return false; // Standard base64 alphabet (RFC 4648 §4) or URL-safe (§5: '-' and '_'), plus '=' padding if (!preg_match('#^[A-Za-z0-9+/_-]*={0,2}$#', $s)) return false; - // Length multiple of 4 (padding may be omitted; it gets added below) - return (strlen($s) % 4) === 0 || (strlen($s) % 4) === 2 || (strlen($s) % 4) === 3; + // Padding may be omitted entirely (it gets added below), but when present it must be + // exactly what the data length requires (RFC 4648) + $unpadded = rtrim($s, '='); + $paddingLength = strlen($s) - strlen($unpadded); + $remainder = strlen($unpadded) % 4; + if ($unpadded === '' || $remainder === 1) return false; + return $paddingLength === 0 || $paddingLength === (4 - $remainder); } public static function padBase64(string $s): string diff --git a/tests/Base64Test.php b/tests/Base64Test.php index 257116577..594108fd2 100644 --- a/tests/Base64Test.php +++ b/tests/Base64Test.php @@ -60,4 +60,18 @@ public function testNonBase64InputIsRejected() $this->assertFalse(Base64::looksLikeBase64("")); $this->assertNull(Base64::tryBase64Decode("!!!")); } + + public function testMalformedPaddingIsRejected() + { + // padding-only, under-padded and over-padded inputs are not RFC 4648 base64: the data + // length (minus padding) decides how much padding is allowed - none, or exactly enough + // to reach a multiple of 4 + $this->assertFalse(Base64::looksLikeBase64("==")); + $this->assertFalse(Base64::looksLikeBase64("A=")); + $this->assertFalse(Base64::looksLikeBase64("QQ=")); + $this->assertFalse(Base64::looksLikeBase64("QUFB==")); + // exact RFC padding stays accepted + $this->assertTrue(Base64::looksLikeBase64("QQQ=")); + $this->assertSame("A\x04", Base64::tryBase64Decode("QQQ=")); + } } From 105fab94893f90d7c69a3157346afb7e05b2a2f0 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 3 Aug 2026 22:02:06 -0300 Subject: [PATCH 3/4] fix(badges): make tryBase64Decode agree with looksLikeBase64 Per Copilot review on PR #576: base64_decode('', true) succeeds with '' so tryBase64Decode('') returned '' instead of null, and under-padded input ('QQ=') was still silently repaired at the decode level even though looksLikeBase64 now rejects it. Short-circuit tryBase64Decode with looksLikeBase64 so the pair holds one invariant: whatever the sniff rejects, the decode returns null for. Behavior-neutral for the sole caller (decodeQRCodeFor only decodes after the sniff passes). Co-Authored-By: Claude --- app/Utils/Base64.php | 3 +++ tests/Base64Test.php | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/app/Utils/Base64.php b/app/Utils/Base64.php index e1de5ba29..3fd518851 100644 --- a/app/Utils/Base64.php +++ b/app/Utils/Base64.php @@ -36,6 +36,9 @@ public static function padBase64(string $s): string public static function tryBase64Decode(string $s): ?string { + // agree with the sniff: base64_decode('', true) "succeeds" with '' and would silently + // repair under-padded input, so anything looksLikeBase64 rejects is not decodable here + if (!self::looksLikeBase64($s)) return null; // strict base64_decode rejects the URL-safe alphabet: normalize it first $s = strtr($s, '-_', '+/'); $padded = self::padBase64($s); diff --git a/tests/Base64Test.php b/tests/Base64Test.php index 594108fd2..6ea934c1f 100644 --- a/tests/Base64Test.php +++ b/tests/Base64Test.php @@ -59,6 +59,9 @@ public function testNonBase64InputIsRejected() $this->assertFalse(Base64::looksLikeBase64("BADGE_X|123|a@b.com|Ada Lovelace")); $this->assertFalse(Base64::looksLikeBase64("")); $this->assertNull(Base64::tryBase64Decode("!!!")); + // tryBase64Decode must agree with looksLikeBase64: base64_decode('', true) "succeeds" + // with '' but an empty artifact is not a decodable payload + $this->assertNull(Base64::tryBase64Decode("")); } public function testMalformedPaddingIsRejected() @@ -70,6 +73,9 @@ public function testMalformedPaddingIsRejected() $this->assertFalse(Base64::looksLikeBase64("A=")); $this->assertFalse(Base64::looksLikeBase64("QQ=")); $this->assertFalse(Base64::looksLikeBase64("QUFB==")); + // the decode agrees with the sniff: what looksLikeBase64 rejects, tryBase64Decode + // rejects too (no silent repair of under-padded input) + $this->assertNull(Base64::tryBase64Decode("QQ=")); // exact RFC padding stays accepted $this->assertTrue(Base64::looksLikeBase64("QQQ=")); $this->assertSame("A\x04", Base64::tryBase64Decode("QQQ=")); From 339a6decec72f47106021e21752dcc76b77ba257 Mon Sep 17 00:00:00 2001 From: smarcet Date: Mon, 3 Aug 2026 22:13:00 -0300 Subject: [PATCH 4/4] docs(badges): note URL-safe base64 accepted on the validateBadge badge param The swagger published from these annotations described the artifact as standard base64 only; since this branch the transport wrapper may also use the RFC 4648 URL-safe alphabet. Co-Authored-By: Claude --- .../Apis/Protected/Summit/OAuth2SummitApiController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php index d552bc377..19aa0bd1e 100644 --- a/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php +++ b/app/Http/Controllers/Apis/Protected/Summit/OAuth2SummitApiController.php @@ -2529,7 +2529,7 @@ public function updateLeadReportSettings($summit_id) { in: 'path', required: true, schema: new OA\Schema(type: 'string'), - description: 'RAW Badge QR scan encoded on BASE 64' + description: 'RAW Badge QR scan encoded on BASE 64 (standard RFC 4648 §4 or URL-safe §5 alphabet)' ) ], responses: [