• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

j0k3r / httplug-ssrf-plugin / 30320713165

28 Jul 2026 01:35AM UTC coverage: 98.907% (-0.5%) from 99.415%
30320713165

Pull #27

github

web-flow
Merge 66939a3d6 into 9aeba2e18
Pull Request #27: Fix DNS pinning

16 of 17 new or added lines in 4 files covered. (94.12%)

181 of 183 relevant lines covered (98.91%)

9.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.47
/src/Url.php
1
<?php
2

3
namespace Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection;
4

5
use Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection\Exception\InvalidURLException;
6
use Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection\Exception\InvalidURLException\InvalidDomainException;
7
use Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection\Exception\InvalidURLException\InvalidIPException;
8
use Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection\Exception\InvalidURLException\InvalidPortException;
9
use Graby\HttpClient\Plugin\ServerSideRequestForgeryProtection\Exception\InvalidURLException\InvalidSchemeException;
10

11
class Url
12
{
13
    /**
14
     * Validates the whole URL.
15
     *
16
     * @throws InvalidURLException
17
     *
18
     * @return array{url: string, host: string, ips: string[]}
19
     */
20
    public static function validateUrl(string $url, Options $options, ?NameResolver $resolver = null): array
21
    {
22
        if (null === $resolver) {
35✔
23
            $resolver = new NativeNameResolver();
18✔
24
        }
25

26
        if ('' === trim($url)) {
35✔
27
            throw new InvalidURLException('Provided URL "' . $url . '" cannot be empty');
1✔
28
        }
29

30
        // Split URL into parts first
31
        $parts = parse_url($url);
34✔
32

33
        if (empty($parts)) {
34✔
34
            throw new InvalidURLException('Error parsing URL "' . $url . '"');
3✔
35
        }
36

37
        if (!isset($parts['host'])) {
31✔
38
            throw new InvalidURLException('Provided URL "' . $url . '" doesn\'t contain a hostname');
2✔
39
        }
40

41
        // If credentials are passed in, but we don't want them, raise an exception
42
        if (!$options->getSendCredentials() && (!empty($parts['user']) || !empty($parts['pass']))) {
29✔
43
            throw new InvalidURLException('Credentials passed in but "sendCredentials" is set to false');
4✔
44
        }
45

46
        if (!isset($parts['scheme'])) {
25✔
47
            $parts['scheme'] = 'http';
×
48
        }
49

50
        $parts['scheme'] = self::validateScheme($parts['scheme'], $options);
25✔
51

52
        // Validate the port
53
        if (isset($parts['port'])) {
19✔
54
            $parts['port'] = self::validatePort($parts['port'], $options);
6✔
55
        }
56

57
        // Validate the host
58
        $host = self::validateHost($parts['host'], $options, $resolver);
15✔
59
        $parts['host'] = $host['host'];
6✔
60
        if ($options->getPinDns()) {
6✔
61
            // Since we're pinning DNS, we replace the host in the URL
62
            // with an IP, then get cURL to send the Host header
63
            $parts['host'] = $host['ips'][0];
2✔
64
        }
65

66
        // Rebuild the URL
67
        $url = self::buildUrl($parts);
6✔
68

69
        return [
70
            'url' => $url,
6✔
71
            'host' => $host['host'],
6✔
72
            'ips' => $host['ips'],
6✔
73
        ];
74
    }
75

76
    /**
77
     * Validates a URL scheme.
78
     *
79
     * @throws InvalidSchemeException
80
     */
81
    public static function validateScheme(string $scheme, Options $options): string
82
    {
83
        $scheme = strtolower($scheme);
25✔
84

85
        // Whitelist always takes precedence over a blacklist
86
        if (!$options->isInList(Options::LIST_WHITELIST, Options::TYPE_SCHEME, $scheme)) {
25✔
87
            throw new InvalidSchemeException('Provided scheme "' . $scheme . '" doesn\'t match whitelisted values: ' . implode(', ', $options->getList(Options::LIST_WHITELIST, Options::TYPE_SCHEME)));
5✔
88
        }
89

90
        if ($options->isInList(Options::LIST_BLACKLIST, Options::TYPE_SCHEME, $scheme)) {
20✔
91
            throw new InvalidSchemeException('Provided scheme "' . $scheme . '" matches a blacklisted value');
1✔
92
        }
93

94
        // Existing value is fine
95
        return $scheme;
19✔
96
    }
97

98
    /**
99
     * Validates a port.
100
     *
101
     * @param string|int $port
102
     *
103
     * @throws InvalidPortException
104
     */
105
    public static function validatePort($port, Options $options): int
106
    {
107
        $port = (string) $port;
6✔
108
        if (!$options->isInList(Options::LIST_WHITELIST, Options::TYPE_PORT, $port)) {
6✔
109
            throw new InvalidPortException('Provided port "' . $port . '" doesn\'t match whitelisted values: ' . implode(', ', $options->getList(Options::LIST_WHITELIST, Options::TYPE_PORT)));
4✔
110
        }
111

112
        if ($options->isInList(Options::LIST_BLACKLIST, Options::TYPE_PORT, $port)) {
2✔
113
            throw new InvalidPortException('Provided port "' . $port . '" matches a blacklisted value');
1✔
114
        }
115

116
        // Existing value is fine
117
        return (int) $port;
1✔
118
    }
119

120
    /**
121
     * Validates a URL host.
122
     *
123
     * @throws InvalidDomainException
124
     * @throws InvalidIPException
125
     *
126
     * @return array{host: string, ips: string[]}
127
     */
128
    public static function validateHost(string $host, Options $options, ?NameResolver $resolver = null): array
129
    {
130
        if (null === $resolver) {
15✔
NEW
131
            $resolver = new NativeNameResolver();
×
132
        }
133

134
        $host = strtolower($host);
15✔
135

136
        // Check the host against the domain lists
137
        if (!$options->isInList(Options::LIST_WHITELIST, Options::TYPE_DOMAIN, $host)) {
15✔
138
            throw new InvalidDomainException('Provided host "' . $host . '" doesn\'t match whitelisted values: ' . implode(', ', $options->getList(Options::LIST_WHITELIST, Options::TYPE_DOMAIN)));
1✔
139
        }
140

141
        if ($options->isInList(Options::LIST_BLACKLIST, Options::TYPE_DOMAIN, $host)) {
14✔
142
            throw new InvalidDomainException('Provided host "' . $host . '" matches a blacklisted value');
2✔
143
        }
144

145
        // Now resolve to an IP and check against the IP lists
146
        $ips = $resolver->resolve($host);
12✔
147
        if ([] === $ips) {
12✔
148
            throw new InvalidDomainException('Provided host "' . $host . '" doesn\'t resolve to an IP address');
1✔
149
        }
150

151
        $whitelistedIps = $options->getList(Options::LIST_WHITELIST, Options::TYPE_IP);
11✔
152

153
        if (!empty($whitelistedIps)) {
11✔
154
            $valid = false;
2✔
155

156
            foreach ($whitelistedIps as $whitelistedIp) {
2✔
157
                foreach ($ips as $ip) {
2✔
158
                    if (self::cidrMatch($ip, $whitelistedIp)) {
2✔
159
                        $valid = true;
1✔
160
                        break 2;
1✔
161
                    }
162
                }
163
            }
164

165
            if (!$valid) {
2✔
166
                throw new InvalidIPException('Provided host "' . $host . '" resolves to "' . implode(', ', $ips) . '", which doesn\'t match whitelisted values: ' . implode(', ', $whitelistedIps));
1✔
167
            }
168
        }
169

170
        $blacklistedIps = $options->getList(Options::LIST_BLACKLIST, Options::TYPE_IP);
10✔
171

172
        if (!empty($blacklistedIps)) {
10✔
173
            foreach ($blacklistedIps as $blacklistedIp) {
10✔
174
                foreach ($ips as $ip) {
10✔
175
                    if (self::cidrMatch($ip, $blacklistedIp)) {
10✔
176
                        throw new InvalidIPException('Provided host "' . $host . '" resolves to "' . implode(', ', $ips) . '", which matches a blacklisted value: ' . $blacklistedIp);
4✔
177
                    }
178
                }
179
            }
180
        }
181

182
        return [
183
            'host' => $host,
6✔
184
            'ips' => $ips,
6✔
185
        ];
186
    }
187

188
    /**
189
     * Re-build a URL based on an array of parts.
190
     *
191
     * @param array{scheme?: string, user?: string, pass?: string, host?: string, port?: int, path?: string, query?: string, fragment?: string} $parts
192
     */
193
    public static function buildUrl(array $parts): string
194
    {
195
        $url = '';
6✔
196

197
        $url .= !empty($parts['scheme']) ? $parts['scheme'] . '://' : '';
6✔
198
        $url .= !empty($parts['user']) ? $parts['user'] : '';
6✔
199
        $url .= !empty($parts['pass']) ? ':' . $parts['pass'] : '';
6✔
200
        // If we have a user or pass, make sure to add an "@"
201
        $url .= !empty($parts['user']) || !empty($parts['pass']) ? '@' : '';
6✔
202
        $url .= !empty($parts['host']) ? $parts['host'] : '';
6✔
203
        $url .= !empty($parts['port']) ? ':' . $parts['port'] : '';
6✔
204
        $url .= !empty($parts['path']) ? $parts['path'] : '';
6✔
205
        $url .= !empty($parts['query']) ? '?' . $parts['query'] : '';
6✔
206
        $url .= !empty($parts['fragment']) ? '#' . $parts['fragment'] : '';
6✔
207

208
        return $url;
6✔
209
    }
210

211
    /**
212
     * Checks a passed in IP against a CIDR.
213
     * See http://stackoverflow.com/questions/594112/matching-an-ip-to-a-cidr-mask-in-php5.
214
     */
215
    public static function cidrMatch(string $ip, string $cidr): bool
216
    {
217
        if (!str_contains($cidr, '/')) {
11✔
218
            // It doesn't have a prefix, just a straight IP match
219
            return $ip === $cidr;
3✔
220
        }
221

222
        list($subnet, $mask) = explode('/', $cidr);
10✔
223

224
        return (ip2long($ip) & ~((1 << (32 - (int) $mask)) - 1)) === ip2long($subnet);
10✔
225
    }
226
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc