Skip to content
Merged
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
12 changes: 11 additions & 1 deletion src/Legacy/IntercomClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,20 @@ public function get($endpoint, $queryParams = [])
*
* @param stdClass $pages
* @return stdClass
* @throws \InvalidArgumentException if the pagination URL is not a valid https:// *.intercom.io address
*/
public function nextPage($pages)
{
$response = $this->sendRequest('GET', $pages->next);
$url = (string) $pages->next;
$parsed = parse_url($url);
$host = $parsed['host'] ?? '';
$validHost = str_ends_with($host, '.intercom.io');
if (($parsed['scheme'] ?? '') !== 'https' || !$validHost) {
throw new \InvalidArgumentException(
'nextPage URL must be an https:// address on the intercom.io domain.'
);
}
$response = $this->sendRequest('GET', $url);
return $this->handleResponse($response);
}

Expand Down
122 changes: 122 additions & 0 deletions tests/Legacy/IntercomClientNextPageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

namespace Intercom\Tests\Legacy;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Intercom\Legacy\IntercomClient;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use stdClass;

class IntercomClientNextPageTest extends TestCase
{
private IntercomClient $client;
private MockHandler $mockHandler;

protected function setUp(): void
{
$this->mockHandler = new MockHandler();
$handlerStack = HandlerStack::create($this->mockHandler);
$httpClient = new Client(['handler' => $handlerStack]);

$this->client = new IntercomClient('test_token');
$this->client->setHttpClient($httpClient);
}

// Happy path — legitimate https://api.intercom.io URL must be followed with credentials attached.
public function testNextPageFollowsLegitimateUrl(): void
{
$this->mockHandler->append(new Response(200, [], json_encode(['data' => []])));

$pages = new stdClass();
$pages->next = 'https://api.intercom.io/contacts?page=2&per_page=50';

$result = $this->client->nextPage($pages);

$this->assertIsObject($result);
$lastRequest = $this->mockHandler->getLastRequest();
$this->assertNotNull($lastRequest);
$this->assertEquals(
'https://api.intercom.io/contacts?page=2&per_page=50',
(string) $lastRequest->getUri()
);
$this->assertStringStartsWith('Bearer ', $lastRequest->getHeaderLine('Authorization'));
}

// Vulnerability closed — attacker-controlled host must be rejected before any HTTP call.
// If the guard were absent MockHandler would throw OutOfBoundsException (no queued response),
// so receiving InvalidArgumentException proves the check fires first.
public function testNextPageRejectsAttackerControlledHost(): void
{
$pages = new stdClass();
$pages->next = 'https://attacker.com/steal';

$this->expectException(InvalidArgumentException::class);
$this->client->nextPage($pages);
}

// SSRF vector — AWS instance metadata endpoint must be rejected.
public function testNextPageRejectsAwsMetadataServiceUrl(): void
{
$pages = new stdClass();
$pages->next = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/';

$this->expectException(InvalidArgumentException::class);
$this->client->nextPage($pages);
}

// Scheme enforcement — http:// to the correct host must still be rejected (no downgrade).
public function testNextPageRejectsPlainHttpEvenForApiIntercomIo(): void
{
$pages = new stdClass();
$pages->next = 'http://api.intercom.io/contacts?page=2';

$this->expectException(InvalidArgumentException::class);
$this->client->nextPage($pages);
}

// EU region — api.eu.intercom.io must be allowed without any configuration.
public function testNextPageAllowsEuRegionalUrl(): void
{
$this->mockHandler->append(new Response(200, [], json_encode(['data' => []])));

$pages = new stdClass();
$pages->next = 'https://api.eu.intercom.io/contacts?page=2&per_page=50';

$result = $this->client->nextPage($pages);

$this->assertIsObject($result);
$lastRequest = $this->mockHandler->getLastRequest();
$this->assertNotNull($lastRequest);
$this->assertEquals(
'https://api.eu.intercom.io/contacts?page=2&per_page=50',
(string) $lastRequest->getUri()
);
}

// AU region — api.au.intercom.io must be allowed without any configuration.
public function testNextPageAllowsAuRegionalUrl(): void
{
$this->mockHandler->append(new Response(200, [], json_encode(['data' => []])));

$pages = new stdClass();
$pages->next = 'https://api.au.intercom.io/contacts?page=2&per_page=50';

$result = $this->client->nextPage($pages);

$this->assertIsObject($result);
}

// Domain suffix bypass — evilintercom.io must not match (requires the dot).
public function testNextPageRejectsDomainThatMerelySuffixesIntercomIo(): void
{
$pages = new stdClass();
$pages->next = 'https://evilintercom.io/steal';

$this->expectException(InvalidArgumentException::class);
$this->client->nextPage($pages);
}
}