Skip to content

Commit 2adbc67

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 3a98a21 commit 2adbc67

3 files changed

Lines changed: 79 additions & 8 deletions

File tree

cs2inspect/errors.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package cs2inspect
2+
3+
import "errors"
4+
5+
// ErrMalformedInspectLink is returned (wrapped) by Deserialize when the input
6+
// cannot be a valid inspect-link payload — odd-length hex, non-hex characters,
7+
// payload too short or too long, or proto bytes that fail to parse cleanly.
8+
//
9+
// Callers can use errors.Is(err, ErrMalformedInspectLink) to detect this
10+
// category of failure.
11+
var ErrMalformedInspectLink = errors.New("malformed inspect URL")

cs2inspect/inspect_link.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var (
1818
hybridURLRe = regexp.MustCompile(`(?i)S\d+A\d+D([0-9A-Fa-f]+)$`)
1919
inspectURLRe = regexp.MustCompile(`(?i)(?:%20|\s|\+)A([0-9A-Fa-f]+)`)
2020
maskedURLRe = regexp.MustCompile(`(?i)csgo_econ_action_preview(?:%20|\s|\+)%?([0-9A-Fa-f]{10,})$`)
21+
hexOnlyRe = regexp.MustCompile(`^[0-9A-Fa-f]+$`)
2122
classicURLRe = regexp.MustCompile(`(?i)csgo_econ_action_preview(?:%20|\s)[SM]\d+A\d+D\d+$`)
2223
hexLettersRe = regexp.MustCompile(`[A-Fa-f]`)
2324
)
@@ -319,21 +320,32 @@ func Serialize(data *ItemPreviewData) (string, error) {
319320
func Deserialize(input string) (*ItemPreviewData, error) {
320321
hexStr := extractHex(input)
321322

323+
preview := input
324+
if len(preview) > 120 {
325+
preview = preview[:100] + "..."
326+
}
327+
322328
if len(hexStr) > 4096 {
323-
preview := input
324-
if len(preview) > 64 {
325-
preview = preview[:64] + "..."
326-
}
327-
return nil, fmt.Errorf("payload too long (max 4096 hex chars): %q", preview)
329+
return nil, fmt.Errorf("%w: payload too long (max 4096 hex chars); input %q", ErrMalformedInspectLink, preview)
330+
}
331+
332+
// Reject malformed hex BEFORE hex.DecodeString so callers get one
333+
// consistent ErrMalformedInspectLink wrap, not the implementation-specific
334+
// hex.ErrLength / hex.InvalidByteError leaking through.
335+
if len(hexStr) == 0 || len(hexStr)%2 != 0 {
336+
return nil, fmt.Errorf("%w: hex payload has invalid length (%d chars, must be even and non-empty); source likely truncated; input %q", ErrMalformedInspectLink, len(hexStr), preview)
337+
}
338+
if !hexOnlyRe.MatchString(hexStr) {
339+
return nil, fmt.Errorf("%w: payload contains non-hex characters; input %q", ErrMalformedInspectLink, preview)
328340
}
329341

330342
raw, err := hex.DecodeString(hexStr)
331343
if err != nil {
332-
return nil, fmt.Errorf("payload too short or invalid hex: %q", input)
344+
return nil, fmt.Errorf("%w: hex decode failed (%v); input %q", ErrMalformedInspectLink, err, preview)
333345
}
334346

335347
if len(raw) < 6 {
336-
return nil, fmt.Errorf("payload too short or invalid hex: %q", input)
348+
return nil, fmt.Errorf("%w: payload too short (%d bytes, need >=6); input %q", ErrMalformedInspectLink, len(raw), preview)
337349
}
338350

339351
key := raw[0]
@@ -349,7 +361,11 @@ func Deserialize(input string) (*ItemPreviewData, error) {
349361
// Layout: [key_byte] [proto_bytes] [4-byte checksum]
350362
protoBytes := decrypted[1 : len(decrypted)-4]
351363

352-
return decodeItem(protoBytes)
364+
item, err := decodeItem(protoBytes)
365+
if err != nil {
366+
return nil, fmt.Errorf("%w: protobuf decode failed (%v); payload likely corrupted or truncated; input %q", ErrMalformedInspectLink, err, preview)
367+
}
368+
return item, nil
353369
}
354370

355371
// IsMasked returns true if the link contains a decodable protobuf payload

cs2inspect/inspect_link_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cs2inspect
22

33
import (
4+
"errors"
45
"math"
56
"strings"
67
"testing"
@@ -1000,3 +1001,46 @@ func TestRoundtrip_PaintKit(t *testing.T) {
10001001
t.Errorf("expected PaintKit=7256, got %d", *result.Keychains[0].PaintKit)
10011002
}
10021003
}
1004+
1005+
// ---------------------------------------------------------------------------
1006+
// Malformed URLs (regression: must reject cleanly with ErrMalformedInspectLink)
1007+
// ---------------------------------------------------------------------------
1008+
1009+
func TestMalformedURLs(t *testing.T) {
1010+
cases := []struct {
1011+
name string
1012+
url string
1013+
}{
1014+
{"truncated mid-keychain (defindex=1, key=0xAD)", "steam://run/730//+csgo_econ_action_preview%20ADBD1050393912ACB5AC8D45AC85A99DA9956A116D5FAEED21ACCFB4A5AFBD348EB0ADAD2D9280ADADDD6F90EDA37510E84D8BEE11CFB4A5ACBD348EB0ADAD2D9280ADAD5D6F906F2B4C13E84D93D591CFB9A5ADBD419EB0ADAD2D9290ADF22010E8B72FB213CFB4A5ADBD549EB0ADAD2D9280ADADED6C90CFD43F10E892DFE513CFB4A5ADBD549EB0ADAD2D9280ADAD85EE902F952210E82EB8A613C52E2D2D2DA1DDA90FACBBA5ADBD89902923AAECE83"},
1015+
{"truncated mid-keychain (defindex=9, key=0xEE)", "steam://run/730//+csgo_econ_action_preview%20EEFE3144332550EFF6E7CE28E8C6EADEEAD642323218EDAE4DEA8CFAE6ECFE35A7F302BFD6D1D3004FCB50ABAE5CF8528CF7E6EEFE0DA7F3394DDED1C3EEEE7EAFD39E25B3D3AB9EAE70D28CFAE6ECFE32A7F3EEEE6ED1D3595B17D3AB9E8E65538CFAE6ECFE64ADF3EEEE6ED1D3E597F3D3AB2EEA1AD58CF7E6EDFE0BCAF302BFD6D1C3EEEEEF2DD3AEF5F552ABEE31A855866D6E6E6EE29EE64CEFF8E6EEFED8D3B6CBCBACABFD70EED1A31F96E7AFBE5"},
1016+
{"truncated mid-keychain (defindex=1, key=0x4A)", "steam://run/730//+csgo_econ_action_preview%204A5A8EFCB1B9F44B524B6AA24B624E7A4E72BACFF6B8490AD449285E42485AAB75576316457577CA2422760F4A413E712853424A5AA679574A4ACA75674A4A8A8A770A7F85760FD04246F4285342495AB279574A4ACA75674A4A8A0A7799714A750F0A5140F7285342495AAD7277EB4547F40F0A00EB7122C9CACACA463A4EE84B5D424A5A4C776C02A10A0F34A5C17407F0145C0A1AA"},
1017+
{"truncated mid-keychain (AK-47 1035, key=0xFA)", "steam://run/730//+csgo_econ_action_preview%20FAEA5766387F45FBE2FDDA71F2D2FECAF3C2142C0C0EF9BA7CFFB2FAAAFA98EEF2F8EA3BFBD7FAFA3ABBC7EA2FB7C7BF9ACC47C698E3F2F9EA03C9E7FAFA7AC5D7FAFABA3AC780CA89C4BFFAAAD9C198E3F2F9EA03C9E7FAFA7AC5D7FAFACEB9C7B60177C4BFAAB82AC698EEF2F9EA03C9E7FAFA7AC5C7C11558C4BFFA43DBC198F5F2FBEA13DEC7759A1147BF9A16F64692797A7A7AF68AF258FBEFF2FAEADEC7DEAC32BBBF0BF9BAC4B760382CC5A2D24"},
1018+
{"truncated mid-keychain (defindex=40, key=0x9F)", "steam://run/730//+csgo_econ_action_preview%209F8F4F504C7C219E87B7BF629EB79CAF9BA73F1D53419CDF0699FD8B979F8F5CCF82050686A0A2F4038821DA17F3FD22FD8B979F8F49D4825C6AB7A0A25F50B224DA7F0CF222FD8B979D8F5DCF82F9F9B9A0A2B7B25422DA6F6F1422FD8B979D8F5DCF822781DAA0A247B731A2DA7F92EF23FD86979F8F5DCF827EE5CBA0B29F9FDFDFA285AD4322DA9FFD8AA3F71C1F1F1F93EF873D9E88979F8FDDA243C05EDFDA4CFB44A0D202B77EDFCF3F339C3DF89"},
1019+
{"truncated mid-keychain (M4A1-S 1130, key=0xFA)", "steam://run/730//+csgo_econ_action_preview%20FAEA5B24060844FBE2C6DA10F2D2FECAF3C2631A3308F9BA47F8B2FAAAFA98EEF2FEEA29B2E781EED4C5C7776B78C4BFFAFA21CD98EEF2FAEA09BCE7F02DD9C5C7FE6C57C7BF7A58ED4698EEF2FEEA6DBDE79C9CDCC5C7929F2F47BFFA461BC398E3F2FBEA15BDE7E57FD1C5D7FAFA8AB8C7C2CEDC46BF12973EC798EEF2FEEA24B8E781EED4C5C75696FCC5BF2AEBF2C792797A7A7AF68AF258FBEDF2FAEAD2C7B3683FBBBF065F8CC5B763A382BAAA0C5"},
1020+
{"truncated mid-keychain (defindex=35, key=0x4D)", "steam://run/730//+csgo_econ_action_preview%204D5D9DF8C7D2F34C556E6DDC4C654E7D4975B2D2AEBB4E0DAB4B2F59454E5D9A70604D4DBD8C7002A356F308CD4603F62F5445495DEB745011C20F72604D4D798F70797F9FF3089D49ABF12F5945495DEB74502B2BAB737045B3FAF308FD4BE8F12F5945495DF868508081417270BFB9D2F308ADD283F12F5945495DF86850AC375972702FA3CBF3083D2EBFF125CECDCDCD413D5AEF4C5A454D5D567084E4F80C08254C547200CE3E1D0D1DF9D24C63938"},
1021+
{"truncated mid-keychain (AK-47 1171, key=0xCF)", "steam://run/730//+csgo_econ_action_preview%20CFDF6258412F71CED7C8EF5CC6E7C9FFCBF7465B3B38CC8F7ACEADD6C7CEDF3BF2D2F2C5D8F0E2CFCFE40CF27F72E1728AF786F5F2ADC0C7CCDF3BF2F241D88EF18A0F03F6F2ADDBC7CCDF3CF2E2CFCF0F8DF27B3FB7F18A6F5ACDF2ADD6C7CCDF3CF2D2C518ECF0E2CFCF8F0EF2D5C29AF18ACFCFD8F5ADDBC7CFDF3CF2E2CFCF6D8DF24DB0DA718A2FA6DBF3A74C4F4F4FC3BFC76DCED8C7CFDF87F27B950C8E8A37D0A3F182A2B8A48F9F4332CD2C2B6"},
1022+
{"truncated mid-keychain (defindex=1 1050, key=0xCE)", "steam://run/730//+csgo_econ_action_preview%20CEDE51082D1C70CFD6CFEE54C6E6CAFEC7F631274538CD8E2DC886CE9ECEACDAC6CDDE0C8DD3CECE4EF1F382996B738B4E5E8D75ACD7C6CEDE0C8DD3CECE4EF1E3CECE8E0FF3A56603708BECDBE170ACD7C6CEDE0C8DD3CECE4EF1E3CECEDE0FF37682A5708B0650FB70ACD7C6CEDE0C8DD3CECE4EF1E3CECEDE0FF34E3CEC738BDAC6F870ACD7C6CDDE798AD3CECE4EF1E3CECE0E0EF3333BC5F18BAE8E9FF2A64D4E4E4EC2BECA6CCFD9C6CEDECFF37C6"},
1023+
{"odd-length bare hex", "ABC"},
1024+
{"empty string", ""},
1025+
{"non-hex characters", "ZZZZZZZZZZZZ"},
1026+
}
1027+
for _, tc := range cases {
1028+
t.Run(tc.name, func(t *testing.T) {
1029+
_, err := Deserialize(tc.url)
1030+
if err == nil {
1031+
t.Fatal("expected error, got nil")
1032+
}
1033+
if !errors.Is(err, ErrMalformedInspectLink) {
1034+
t.Errorf("expected errors.Is(err, ErrMalformedInspectLink) == true, got %v", err)
1035+
}
1036+
})
1037+
}
1038+
}
1039+
1040+
func TestMalformedURLs_PayloadTooLong_WrapsErrMalformedInspectLink(t *testing.T) {
1041+
longHex := strings.Repeat("00", 2049)
1042+
_, err := Deserialize(longHex)
1043+
if !errors.Is(err, ErrMalformedInspectLink) {
1044+
t.Errorf("expected ErrMalformedInspectLink wrap, got %v", err)
1045+
}
1046+
}

0 commit comments

Comments
 (0)