Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
18 changes: 14 additions & 4 deletions app/Utils/Base64.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Comment thread
smarcet marked this conversation as resolved.
Expand Down
83 changes: 83 additions & 0 deletions tests/Base64Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php namespace Tests;

/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Utils\Base64;
use PHPUnit\Framework\TestCase;

/**
* Class Base64Test
* Badge/ticket QR artifacts travel base64-encoded as a URL path segment; the standard
* alphabet's "/" can never survive path routing (Laravel rawurldecodes the path before
* matching), so the helper must also accept the URL-safe alphabet (base64url, RFC 4648 §5:
* "-" for "+", "_" for "/"). Standard base64 must keep decoding byte-identical.
*/
final class Base64Test extends TestCase
{
public function testStandardAlphabetStillDecodes()
{
// "+/+/" = indices 62,63,62,63 = bytes FB FF BF (hand-derived from the RFC 4648 table)
$this->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(""));
}
Comment thread
smarcet marked this conversation as resolved.

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="));
}
}
Loading