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

daycry / jobs / 26886467550

03 Jun 2026 01:01PM UTC coverage: 88.948% (+14.0%) from 74.974%
26886467550

push

github

web-flow
v3.0: single clean architecture (remove V1, lease-based queues, secure-by-default)

Complete v3.0 rewrite into a single, clean architecture. The v1 API and the V2\ scaffolding
are removed (no facade, no dual code); the package passes PHPStan level 6 + strict-rules +
codeigniter with NO baseline.

- Definition: Jobs::define()->...->dispatch() fluent builder -> immutable JobDefinition.
- Handlers decoupled from the god-object (JobHandlerInterface / AbstractJobHandler / TypedJobHandler + JobContext).
- One QueueBackend contract (enqueue/fetch(lease)/ack/nack(delay)/abandon/reapExpired) with 5 backends:
  Sync, Database, Redis, Beanstalk, ServiceBus.
- Runtime: one attempt per fetch; real interrupting Timeout; opt-in idempotency; single-instance lock.
- Worker/Cron: jobs:queue:work, jobs:queue:reap, jobs:cronjob:run, jobs:queue:purge.
- Secure-by-default: HMAC-signed envelopes, per-queue handler allowlist, ShellHandler deny-by-default,
  EventHandler allowlist, UrlHandler anti-SSRF.

Resolves audit findings #1,#2,#3,#4,#5,#6,#7,#8,#10,#12,#13,#17,#18,#19,#20,#22.
Tests: 359 (Beanstalk live); line coverage 88.9%; PHPStan/Psalm/Rector/cs green on PHP 8.2-8.5.

BREAKING CHANGE: v1 API removed. See docs/MIGRATION-v1-to-v3.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

983 of 1103 new or added lines in 43 files covered. (89.12%)

15 existing lines in 3 files now uncovered.

1497 of 1683 relevant lines covered (88.95%)

7.55 hits per line

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

70.45
/src/Handlers/UrlHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of Daycry Queues.
7
 *
8
 * (c) Daycry <daycry9@proton.me>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace Daycry\Jobs\Handlers;
15

16
use Daycry\Jobs\Exceptions\JobException;
17
use Daycry\Jobs\Execution\JobContext;
18

19
/**
20
 * Performs an HTTP request via the CodeIgniter curlrequest service.
21
 *
22
 * SSRF-hardened: scheme allowlist (http/https only), rejection of private/reserved
23
 * IPv4 and IPv6 targets (resolving A/AAAA records), SSL verification forced on, and
24
 * redirects disabled so a 3xx cannot bounce the request to an internal target.
25
 *
26
 * Residual risk: DNS rebinding (a different IP at cURL time than at validation time)
27
 * is not fully mitigated; document handlers that need it should pin resolution.
28
 *
29
 * Payload: ['method' => string, 'url' => string, 'options' => array(optional)].
30
 */
31
final class UrlHandler extends AbstractJobHandler
32
{
33
    private const ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
34
    private const ALLOWED_SCHEMES = ['http', 'https'];
35

36
    public function handle(JobContext $ctx): mixed
37
    {
38
        $payload = $ctx->payload;
19✔
39
        $this->validate($payload);
19✔
40

NEW
41
        $options = $payload['options'] ?? [];
×
42
        // Prevent disabling SSL verification via options.
NEW
43
        unset($options['verify'], $options[CURLOPT_SSL_VERIFYPEER], $options[CURLOPT_SSL_VERIFYHOST]);
×
44
        // SSRF: never follow redirects (a 3xx could point at an internal host).
NEW
45
        $options['allow_redirects'] = false;
×
46

NEW
47
        return service('curlrequest')->request($payload['method'], $payload['url'], $options)->getBody();
×
48
    }
49

50
    private function validate(mixed $payload): void
51
    {
52
        if (! is_array($payload)) {
19✔
53
            throw JobException::validationError('UrlHandler payload must be an array with method and url keys.');
2✔
54
        }
55

56
        if (! isset($payload['url']) || ! is_string($payload['url']) || $payload['url'] === '') {
17✔
57
            throw JobException::validationError('UrlHandler payload must contain a valid url string.');
3✔
58
        }
59

60
        if (! isset($payload['method']) || ! is_string($payload['method']) || $payload['method'] === '') {
14✔
61
            throw JobException::validationError('UrlHandler payload must contain a valid method string.');
3✔
62
        }
63

64
        $method = strtoupper($payload['method']);
11✔
65
        if (! in_array($method, self::ALLOWED_METHODS, true)) {
11✔
66
            throw JobException::forInvalidMethod($method);
2✔
67
        }
68

69
        if (filter_var($payload['url'], FILTER_VALIDATE_URL) === false) {
9✔
70
            throw JobException::validationError('UrlHandler payload contains an invalid URL.');
1✔
71
        }
72

73
        $this->blockInternalUrls($payload['url']);
8✔
74
    }
75

76
    /**
77
     * Enforce scheme allowlist and reject internal/private/reserved targets.
78
     */
79
    private function blockInternalUrls(string $url): void
80
    {
81
        $parts = parse_url($url);
8✔
82
        if ($parts === false) {
8✔
NEW
83
            throw JobException::validationError('UrlHandler could not parse URL.');
×
84
        }
85

86
        $scheme = strtolower($parts['scheme'] ?? '');
8✔
87
        if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) {
8✔
88
            throw JobException::validationError("UrlHandler scheme '{$scheme}' is not allowed (only http/https).");
3✔
89
        }
90

91
        $host = $parts['host'] ?? null;
5✔
92
        if (! is_string($host) || $host === '') {
5✔
NEW
93
            throw JobException::validationError('UrlHandler could not parse host from URL.');
×
94
        }
95

96
        // IPv6 literal hosts arrive bracketed per RFC 3986: http://[::1]/
97
        $literal = trim($host, '[]');
5✔
98
        if (filter_var($literal, FILTER_VALIDATE_IP)) {
5✔
99
            $this->validatePublicIp($literal);
4✔
100

NEW
101
            return;
×
102
        }
103

104
        // Hostname: resolve all A and AAAA records and reject if ANY points internal.
105
        $records = @dns_get_record($host, DNS_A | DNS_AAAA);
1✔
106

107
        if (! is_array($records) || $records === []) {
1✔
108
            $ip = gethostbyname($host);
1✔
109
            if ($ip === $host || ! filter_var($ip, FILTER_VALIDATE_IP)) {
1✔
110
                throw JobException::validationError("UrlHandler could not resolve host '{$host}'.");
1✔
111
            }
NEW
112
            $this->validatePublicIp($ip);
×
113

NEW
114
            return;
×
115
        }
116

NEW
117
        foreach ($records as $rec) {
×
NEW
118
            $ip = $rec['ip'] ?? $rec['ipv6'] ?? null;
×
NEW
119
            if (is_string($ip) && $ip !== '') {
×
NEW
120
                $this->validatePublicIp($ip);
×
121
            }
122
        }
123
    }
124

125
    private function validatePublicIp(string $ip): void
126
    {
127
        if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
4✔
128
            throw JobException::validationError("UrlHandler does not allow requests to internal IP '{$ip}'.");
4✔
129
        }
130
    }
131
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc