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: [ diff --git a/app/Utils/Base64.php b/app/Utils/Base64.php index 2a742e170..3fd518851 100644 --- a/app/Utils/Base64.php +++ b/app/Utils/Base64.php @@ -17,10 +17,15 @@ 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) - return (strlen($s) % 4) === 0 || (strlen($s) % 4) === 2 || (strlen($s) % 4) === 3; + // 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; + // 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 @@ -31,6 +36,11 @@ 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); $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..6ea934c1f --- /dev/null +++ b/tests/Base64Test.php @@ -0,0 +1,83 @@ +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("!!!")); + // 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() + { + // 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==")); + // 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=")); + } +}