PHP Version
8.4
CodeIgniter4 Version
v4.7.4
Shield Version
v1.4.0
Which operating systems have you tested for this bug?
macOS
Which server did you use?
apache
Database
MYSQL 5
Did you customize Shield?
no
What happened?
PwnedValidator never reaches the HaveIBeenPwned API. Every password check
fails with a DNS error, because the request is sent to a host literally named
range instead of api.pwnedpasswords.com.
The cause is an option-key mismatch: PwnedValidator passes the Guzzle
option name base_uri, but CodeIgniter's CURLRequest only ever reads
baseURI.
Since PwnedValidator is commented out in the default Config\Auth, this
stays invisible until someone enables it — at which point every password
change and every registration fails, since Shield converts the failure into
a thrown AuthenticationException.
Steps to reproduce
-
Enable the validator in app/Config/Auth.php:
public array $passwordValidators = [
CompositionValidator::class,
NothingPersonalValidator::class,
DictionaryValidator::class,
PwnedValidator::class, // <- enabled
];
-
Attempt any password validation (registration, or a rule using
strong_password).
-
Observed result:
CodeIgniter\Shield\Exceptions\AuthenticationException
cURL error 6: Could not resolve host: range
Request URL: http://range/1B485
Note the URL: http://range/1B485. The base URI was never applied, so the
relative path range/<prefix> was treated as a hostname.
Steps to Reproduce
In short: the option key Shield sends is not the one CodeIgniter reads.
Shield passes the Guzzle-style base_uri; CodeIgniter's CURLRequest only
ever looks for baseURI. The base URI is therefore never applied, and the
relative path is resolved against an empty base.
Line numbers below are as of CodeIgniter v4.7.4 and Shield v1.4.0; the
logic is what matters, and it is unchanged across recent versions — develop
still passes base_uri at the time of writing.
PwnedValidator::check() — src/Authentication/Passwords/PwnedValidator.php
$client = Services::curlrequest([
'base_uri' => 'https://api.pwnedpasswords.com/', // Guzzle-style key
]);
$response = $client->get('range/' . $rangeHash, [...]);
CodeIgniter never reads that key. Both places that could apply it look for
baseURI:
system/Config/Services.php — curlrequest() (as of CI 4.7.4)
return new CURLRequest(
$config,
new URI($options['baseURI'] ?? null), // 'base_uri' -> null
$response,
$options,
);
system/HTTP/CURLRequest.php — parseOptions() (line 311 as of CI 4.7.4)
if (array_key_exists('baseURI', $options)) { // 'base_uri' -> false
$this->baseURI = $this->baseURI->setURI($options['baseURI']);
unset($options['baseURI']);
}
So $this->baseURI stays empty, and CURLRequest::prepareURL() (line 352 as
of CI 4.7.4) does:
$uri = $this->baseURI->resolveRelativeURI($url); // resolves against nothing
which yields range/1B485. cURL then interprets the first segment as a host.
Expected Output
One-line change in PwnedValidator::check():
$client = Services::curlrequest([
- 'base_uri' => 'https://api.pwnedpasswords.com/',
+ 'baseURI' => 'https://api.pwnedpasswords.com/',
]);
Two related points worth considering, though they are separate from the bug:
-
Services::curlrequest() returns a shared instance by default. If any
other code created the shared curlrequest service earlier in the request,
the options passed here are silently ignored and the base URI is whatever
that first caller set. Passing getShared: false — or using the absolute
URL in get() — would make the validator independent of call order.
-
An unreachable API currently blocks all password changes. A network
failure or an HIBP outage raises AuthenticationException, so users cannot
change their password at all. Treating an unreachable API as "not verified"
(log a warning, let the password through, the other validators still apply)
would avoid making a third-party outage a site-wide lockout. Happy to open a
separate issue for that if you prefer to keep this one focused on the key
name.
Anything else?
No response
PHP Version
8.4
CodeIgniter4 Version
v4.7.4
Shield Version
v1.4.0
Which operating systems have you tested for this bug?
macOS
Which server did you use?
apache
Database
MYSQL 5
Did you customize Shield?
no
What happened?
PwnedValidatornever reaches the HaveIBeenPwned API. Every password checkfails with a DNS error, because the request is sent to a host literally named
rangeinstead ofapi.pwnedpasswords.com.The cause is an option-key mismatch:
PwnedValidatorpasses the Guzzleoption name
base_uri, but CodeIgniter'sCURLRequestonly ever readsbaseURI.Since
PwnedValidatoris commented out in the defaultConfig\Auth, thisstays invisible until someone enables it — at which point every password
change and every registration fails, since Shield converts the failure into
a thrown
AuthenticationException.Steps to reproduce
Enable the validator in
app/Config/Auth.php:Attempt any password validation (registration, or a rule using
strong_password).Observed result:
Note the URL:
http://range/1B485. The base URI was never applied, so therelative path
range/<prefix>was treated as a hostname.Steps to Reproduce
In short: the option key Shield sends is not the one CodeIgniter reads.
Shield passes the Guzzle-style
base_uri; CodeIgniter'sCURLRequestonlyever looks for
baseURI. The base URI is therefore never applied, and therelative path is resolved against an empty base.
Line numbers below are as of CodeIgniter v4.7.4 and Shield v1.4.0; the
logic is what matters, and it is unchanged across recent versions —
developstill passes
base_uriat the time of writing.PwnedValidator::check()— src/Authentication/Passwords/PwnedValidator.phpCodeIgniter never reads that key. Both places that could apply it look for
baseURI:system/Config/Services.php—curlrequest()(as of CI 4.7.4)system/HTTP/CURLRequest.php—parseOptions()(line 311 as of CI 4.7.4)So
$this->baseURIstays empty, andCURLRequest::prepareURL()(line 352 asof CI 4.7.4) does:
which yields
range/1B485. cURL then interprets the first segment as a host.Expected Output
One-line change in
PwnedValidator::check():Two related points worth considering, though they are separate from the bug:
Services::curlrequest()returns a shared instance by default. If anyother code created the shared
curlrequestservice earlier in the request,the options passed here are silently ignored and the base URI is whatever
that first caller set. Passing
getShared: false— or using the absoluteURL in
get()— would make the validator independent of call order.An unreachable API currently blocks all password changes. A network
failure or an HIBP outage raises
AuthenticationException, so users cannotchange their password at all. Treating an unreachable API as "not verified"
(log a warning, let the password through, the other validators still apply)
would avoid making a third-party outage a site-wide lockout. Happy to open a
separate issue for that if you prefer to keep this one focused on the key
name.
Anything else?
No response