Skip to content

Commit 41f41ee

Browse files
committed
fix: reject malformed inspect URLs with a clean MalformedInspectLinkException
Pre-validate hex parity, length and character set before any binary decode so callers get one consistent exception type for "this URL is bad" instead of language-specific implementation errors leaking through (E_WARNING in PHP, silent buffer truncation in JS/TS, FormatException in C#, etc.). Wrap proto-decode failures too. The new exception extends the existing parent class for BC. Add 9 real-world malformed URLs as negative test fixtures.
1 parent 2602cf8 commit 41f41ee

3 files changed

Lines changed: 143 additions & 3 deletions

File tree

src/InspectLink.php

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,34 @@ public static function deserialize(string $hexOrUrl): ItemPreviewData
7070
);
7171
}
7272

73+
// Reject malformed hex BEFORE hex2bin: odd-length input triggers E_WARNING
74+
// ("Hexadecimal input string must have an even length") which Symfony / other
75+
// global error handlers escalate to ErrorException — even though hex2bin would
76+
// otherwise return false. Validate up-front to throw a clean, descriptive error.
77+
if (strlen($hex) === 0 || strlen($hex) % 2 !== 0) {
78+
throw new MalformedInspectLinkException(
79+
sprintf(
80+
'Malformed inspect URL: hex payload has invalid length (%d chars, must be even and non-empty). The source likely truncated the URL. Input: "%s"',
81+
strlen($hex),
82+
self::abbreviateForError($hexOrUrl),
83+
),
84+
);
85+
}
86+
87+
if (1 !== preg_match('/^[0-9A-Fa-f]+$/', $hex)) {
88+
throw new MalformedInspectLinkException(
89+
sprintf(
90+
'Malformed inspect URL: payload contains non-hex characters. Input: "%s"',
91+
self::abbreviateForError($hexOrUrl),
92+
),
93+
);
94+
}
95+
7396
$raw = hex2bin($hex);
7497

7598
if ($raw === false || strlen($raw) < 6) {
76-
throw new \InvalidArgumentException(
77-
sprintf('Payload too short or invalid hex: "%s"', $hexOrUrl),
99+
throw new MalformedInspectLinkException(
100+
sprintf('Malformed inspect URL: payload too short (%d bytes, need ≥6). Input: "%s"', $raw === false ? 0 : strlen($raw), self::abbreviateForError($hexOrUrl)),
78101
);
79102
}
80103

@@ -92,7 +115,24 @@ public static function deserialize(string $hexOrUrl): ItemPreviewData
92115
// Layout: [key_byte] [proto_bytes] [4-byte checksum]
93116
$protoBytes = substr($decrypted, 1, -4);
94117

95-
return self::decodeItem($protoBytes);
118+
try {
119+
return self::decodeItem($protoBytes);
120+
} catch (\Throwable $e) {
121+
throw new MalformedInspectLinkException(
122+
sprintf(
123+
'Malformed inspect URL: protobuf decode failed (%s). The payload is likely corrupted or truncated. Input: "%s"',
124+
$e->getMessage(),
125+
self::abbreviateForError($hexOrUrl),
126+
),
127+
0,
128+
$e,
129+
);
130+
}
131+
}
132+
133+
private static function abbreviateForError(string $input): string
134+
{
135+
return strlen($input) > 120 ? substr($input, 0, 100) . '...' : $input;
96136
}
97137

98138
// ------------------------------------------------------------------
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace VlyDev\Steam;
6+
7+
/**
8+
* Thrown by {@see InspectLink::deserialize()} when the input cannot be a valid
9+
* inspect-link payload — odd-length hex, non-hex characters, payload shorter
10+
* than the minimum, or proto bytes that fail to parse cleanly.
11+
*
12+
* Extends \InvalidArgumentException for backwards compatibility with callers
13+
* that catch the parent class.
14+
*/
15+
class MalformedInspectLinkException extends \InvalidArgumentException
16+
{
17+
}

tests/InspectLinkTest.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use VlyDev\Steam\InspectLink;
66
use VlyDev\Steam\ItemPreviewData;
7+
use VlyDev\Steam\MalformedInspectLinkException;
78
use VlyDev\Steam\Sticker;
89
use PHPUnit\Framework\TestCase;
910

@@ -652,4 +653,86 @@ public function testRoundtrip_PaintKit(): void
652653
$this->assertSame(37, $result->keychains[0]->stickerId);
653654
$this->assertSame(7256, $result->keychains[0]->paintKit);
654655
}
656+
657+
// -----------------------------------------------------------------------
658+
// Malformed URLs (regression: must not emit E_WARNING from hex2bin)
659+
//
660+
// Real-world URLs collected from a buggy upstream source that truncates
661+
// the hex payload mid-keychain, leaving an odd number of hex characters
662+
// and a missing CRC. The library must reject them with a clean
663+
// MalformedInspectLinkException — never let hex2bin trigger a PHP warning
664+
// (which Symfony's error handler would escalate to ErrorException and log).
665+
// -----------------------------------------------------------------------
666+
667+
/** @return array<string, array{string}> */
668+
public static function malformedUrlProvider(): array
669+
{
670+
return [
671+
'truncated mid-keychain (defindex=1, key=0xAD)' => [
672+
'steam://run/730//+csgo_econ_action_preview%20ADBD1050393912ACB5AC8D45AC85A99DA9956A116D5FAEED21ACCFB4A5AFBD348EB0ADAD2D9280ADADDD6F90EDA37510E84D8BEE11CFB4A5ACBD348EB0ADAD2D9280ADAD5D6F906F2B4C13E84D93D591CFB9A5ADBD419EB0ADAD2D9290ADF22010E8B72FB213CFB4A5ADBD549EB0ADAD2D9280ADADED6C90CFD43F10E892DFE513CFB4A5ADBD549EB0ADAD2D9280ADAD85EE902F952210E82EB8A613C52E2D2D2DA1DDA90FACBBA5ADBD89902923AAECE83',
673+
],
674+
'truncated mid-keychain (defindex=9, key=0xEE)' => [
675+
'steam://run/730//+csgo_econ_action_preview%20EEFE3144332550EFF6E7CE28E8C6EADEEAD642323218EDAE4DEA8CFAE6ECFE35A7F302BFD6D1D3004FCB50ABAE5CF8528CF7E6EEFE0DA7F3394DDED1C3EEEE7EAFD39E25B3D3AB9EAE70D28CFAE6ECFE32A7F3EEEE6ED1D3595B17D3AB9E8E65538CFAE6ECFE64ADF3EEEE6ED1D3E597F3D3AB2EEA1AD58CF7E6EDFE0BCAF302BFD6D1C3EEEEEF2DD3AEF5F552ABEE31A855866D6E6E6EE29EE64CEFF8E6EEFED8D3B6CBCBACABFD70EED1A31F96E7AFBE5',
676+
],
677+
'truncated mid-keychain (defindex=1, key=0x4A)' => [
678+
'steam://run/730//+csgo_econ_action_preview%204A5A8EFCB1B9F44B524B6AA24B624E7A4E72BACFF6B8490AD449285E42485AAB75576316457577CA2422760F4A413E712853424A5AA679574A4ACA75674A4A8A8A770A7F85760FD04246F4285342495AB279574A4ACA75674A4A8A0A7799714A750F0A5140F7285342495AAD7277EB4547F40F0A00EB7122C9CACACA463A4EE84B5D424A5A4C776C02A10A0F34A5C17407F0145C0A1AA',
679+
],
680+
'truncated mid-keychain (AK-47 1035, key=0xFA)' => [
681+
'steam://run/730//+csgo_econ_action_preview%20FAEA5766387F45FBE2FDDA71F2D2FECAF3C2142C0C0EF9BA7CFFB2FAAAFA98EEF2F8EA3BFBD7FAFA3ABBC7EA2FB7C7BF9ACC47C698E3F2F9EA03C9E7FAFA7AC5D7FAFABA3AC780CA89C4BFFAAAD9C198E3F2F9EA03C9E7FAFA7AC5D7FAFACEB9C7B60177C4BFAAB82AC698EEF2F9EA03C9E7FAFA7AC5C7C11558C4BFFA43DBC198F5F2FBEA13DEC7759A1147BF9A16F64692797A7A7AF68AF258FBEFF2FAEADEC7DEAC32BBBF0BF9BAC4B760382CC5A2D24',
682+
],
683+
'truncated mid-keychain (defindex=40, key=0x9F)' => [
684+
'steam://run/730//+csgo_econ_action_preview%209F8F4F504C7C219E87B7BF629EB79CAF9BA73F1D53419CDF0699FD8B979F8F5CCF82050686A0A2F4038821DA17F3FD22FD8B979F8F49D4825C6AB7A0A25F50B224DA7F0CF222FD8B979D8F5DCF82F9F9B9A0A2B7B25422DA6F6F1422FD8B979D8F5DCF822781DAA0A247B731A2DA7F92EF23FD86979F8F5DCF827EE5CBA0B29F9FDFDFA285AD4322DA9FFD8AA3F71C1F1F1F93EF873D9E88979F8FDDA243C05EDFDA4CFB44A0D202B77EDFCF3F339C3DF89',
685+
],
686+
'truncated mid-keychain (M4A1-S 1130, key=0xFA)' => [
687+
'steam://run/730//+csgo_econ_action_preview%20FAEA5B24060844FBE2C6DA10F2D2FECAF3C2631A3308F9BA47F8B2FAAAFA98EEF2FEEA29B2E781EED4C5C7776B78C4BFFAFA21CD98EEF2FAEA09BCE7F02DD9C5C7FE6C57C7BF7A58ED4698EEF2FEEA6DBDE79C9CDCC5C7929F2F47BFFA461BC398E3F2FBEA15BDE7E57FD1C5D7FAFA8AB8C7C2CEDC46BF12973EC798EEF2FEEA24B8E781EED4C5C75696FCC5BF2AEBF2C792797A7A7AF68AF258FBEDF2FAEAD2C7B3683FBBBF065F8CC5B763A382BAAA0C5',
688+
],
689+
'truncated mid-keychain (defindex=35, key=0x4D)' => [
690+
'steam://run/730//+csgo_econ_action_preview%204D5D9DF8C7D2F34C556E6DDC4C654E7D4975B2D2AEBB4E0DAB4B2F59454E5D9A70604D4DBD8C7002A356F308CD4603F62F5445495DEB745011C20F72604D4D798F70797F9FF3089D49ABF12F5945495DEB74502B2BAB737045B3FAF308FD4BE8F12F5945495DF868508081417270BFB9D2F308ADD283F12F5945495DF86850AC375972702FA3CBF3083D2EBFF125CECDCDCD413D5AEF4C5A454D5D567084E4F80C08254C547200CE3E1D0D1DF9D24C63938',
691+
],
692+
'truncated mid-keychain (AK-47 1171, key=0xCF)' => [
693+
'steam://run/730//+csgo_econ_action_preview%20CFDF6258412F71CED7C8EF5CC6E7C9FFCBF7465B3B38CC8F7ACEADD6C7CEDF3BF2D2F2C5D8F0E2CFCFE40CF27F72E1728AF786F5F2ADC0C7CCDF3BF2F241D88EF18A0F03F6F2ADDBC7CCDF3CF2E2CFCF0F8DF27B3FB7F18A6F5ACDF2ADD6C7CCDF3CF2D2C518ECF0E2CFCF8F0EF2D5C29AF18ACFCFD8F5ADDBC7CFDF3CF2E2CFCF6D8DF24DB0DA718A2FA6DBF3A74C4F4F4FC3BFC76DCED8C7CFDF87F27B950C8E8A37D0A3F182A2B8A48F9F4332CD2C2B6',
694+
],
695+
'truncated mid-keychain (defindex=1 1050, key=0xCE)' => [
696+
'steam://run/730//+csgo_econ_action_preview%20CEDE51082D1C70CFD6CFEE54C6E6CAFEC7F631274538CD8E2DC886CE9ECEACDAC6CDDE0C8DD3CECE4EF1F382996B738B4E5E8D75ACD7C6CEDE0C8DD3CECE4EF1E3CECE8E0FF3A56603708BECDBE170ACD7C6CEDE0C8DD3CECE4EF1E3CECEDE0FF37682A5708B0650FB70ACD7C6CEDE0C8DD3CECE4EF1E3CECEDE0FF34E3CEC738BDAC6F870ACD7C6CDDE798AD3CECE4EF1E3CECE0E0EF3333BC5F18BAE8E9FF2A64D4E4E4EC2BECA6CCFD9C6CEDECFF37C6',
697+
],
698+
'odd-length bare hex' => ['ABC'],
699+
'empty string' => [''],
700+
'non-hex characters' => ['ZZZZZZZZZZZZ'],
701+
];
702+
}
703+
704+
#[\PHPUnit\Framework\Attributes\DataProvider('malformedUrlProvider')]
705+
public function testMalformedUrlThrowsCleanException(string $url): void
706+
{
707+
$this->expectException(MalformedInspectLinkException::class);
708+
InspectLink::deserialize($url);
709+
}
710+
711+
#[\PHPUnit\Framework\Attributes\DataProvider('malformedUrlProvider')]
712+
public function testMalformedUrlEmitsNoPhpWarning(string $url): void
713+
{
714+
// Promote any E_WARNING to ErrorException so we can detect it.
715+
// Bug pre-fix: hex2bin('odd-hex') emitted E_WARNING → Symfony escalated → log noise.
716+
// Post-fix: parity is checked before hex2bin, so no warning should ever be emitted.
717+
$errorHandler = static function (int $errno, string $errstr, string $errfile, int $errline): bool {
718+
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
719+
};
720+
721+
set_error_handler($errorHandler, E_WARNING);
722+
try {
723+
InspectLink::deserialize($url);
724+
$this->fail('Expected MalformedInspectLinkException was not thrown');
725+
} catch (MalformedInspectLinkException) {
726+
// Expected — clean rejection without a PHP warning.
727+
$this->addToAssertionCount(1);
728+
} finally {
729+
restore_error_handler();
730+
}
731+
}
732+
733+
public function testMalformedUrlExceptionExtendsInvalidArgument(): void
734+
{
735+
// BC: existing callers catching \InvalidArgumentException must still work.
736+
$this->assertTrue(is_subclass_of(MalformedInspectLinkException::class, \InvalidArgumentException::class));
737+
}
655738
}

0 commit comments

Comments
 (0)