Case-insensitive webhook headers in the Svix PHP and Ruby SDKs, and the PSR-7 array trap

A Svix fix lets verify() find Svix-Id as well as svix-id. With PSR-7 headers the keys now match but the values are arrays, and intval turns the timestamp into 1.

Category
Code review
Published
Reading time
2 min read

The problem

Svix sends svix-id, svix-timestamp and svix-signature (or the unbranded webhook-* names). The PHP and Ruby SDKs looked them up with exact lower-case keys:

PHP
isset($headers['svix-id'])

HTTP field names are case-insensitive (RFC 9110), and many frameworks hand headers to application code re-cased, for example as Svix-Id. Such a caller got "Missing required headers" although every header was there. Issue #1013, open since 2023, asked for a case-insensitive fallback that only runs when the fast lower-case lookup fails.

The proposed fix

PHP lowercases the keys only on the fallback path and then repeats the same branded-or-unbranded check:

php/src/Webhook.php
if (is_array($headers)) {
    $lowerHeaders = array_change_key_case($headers, CASE_LOWER);
} elseif (is_iterable($headers)) {
    $lowerHeaders = [];
    foreach ($headers as $k => $v) {
        $lowerHeaders[strtolower($k)] = $v;
    }
}

Ruby does the same with transform_keys { |k| k.to_s.downcase }. Both have tests with mixed-case names.

What I found in review

PSR-7 headers: right keys, wrong value type

A common way to call the PHP SDK is with PSR-7 headers: $wh->verify($body, $request->getHeaders()). In that shape the keys keep their original case, and every value is an array of strings, because a header can appear more than once.

Before the pull request this input failed clearly with "Missing required headers". After it, the keys match, but the values are arrays:

PHP
$timestamp = intval(['1700000000'], 10); // int(1)
if ($timestamp < ($now - $this->tolerance)) {
    throw new Exception\WebhookVerificationException("Message timestamp too old");
}

intval on a non-empty array returns 1, so the caller now sees "Message timestamp too old", which points in completely the wrong direction. Had execution reached the signature, explode(' ', $array) would throw a TypeError. I checked both on PHP 8.4. The suggestion was to normalize values in both fallback branches and add a PSR-7 shaped test:

PHP
$v = is_array($v) ? reset($v) : $v;

Two SDKs, two fallback rules

The PHP fallback requires all three headers from one brand: three svix-* or three webhook-*. The Ruby fallback picks each field independently (svix-id || webhook-id, and so on), so it can combine svix-id with webhook-signature. That is not a security problem, since every value still goes through the HMAC, but the SDKs now behave differently on mixed input. Grouping by brand in Ruby would keep them aligned. The k.to_s in the Ruby version also handles symbol keys, which is a nice detail.

What happened next

Takeaways

  • Fixing the lookup is half of header handling. The value shape (string or list) is the other half.
  • PHP's loose conversions (intval on an array) turn type errors into wrong numbers. Validate types at the boundary.
  • When SDKs in several languages implement one protocol, keep their edge cases identical, including on malformed input.

References

Code and history behind this post

More

Theme