{"id":"GHSA-g7m4-839x-ch6v","aliases":[],"status":"published","title":"spomky-labs/otphp: Unbounded digits parameter in a provisioning URI triggers an uncaught DivisionByZeroError in OTP generation","description":"## Summary\n\nThe `digits` parameter parsed from a provisioning URI is validated only with a lower bound (`$value \u003e 0`) and has no upper bound (`src/OTP.php:353-357`). OTP generation computes `$code % (10 ** $this-\u003egetDigits())` (`src/OTP.php:283`). When `digits` is large enough that `10 ** digits` overflows PHP's integer range and the `(int)` cast yields `0` (around `digits \u003e= 40` on 64-bit PHP 8.x), the modulo operand becomes `0` and PHP raises a `DivisionByZeroError`.\n\n## Impact\n\n`OTPHP\\Factory::loadFromProvisioningUri()` forwards the attacker-controlled `digits` query value to `setParameter('digits', $value)`, so a hostile URI such as `otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50` produces an OTP object whose `at()`, `now()`, and `verify()` all throw `DivisionByZeroError`. Because `DivisionByZeroError` extends `Error` (not `Exception`), callers that guard OTP generation with a `catch (\\Exception)` do not catch it, turning a malformed URI into an unhandled fatal error (denial of service of the verification path).\n\nMeasured threshold on PHP 8.3: `digits = 30` works, `digits \u003e= 40` throws `DivisionByZeroError: Modulo by zero`.\n\n## Affected component\n\n- `src/OTP.php:353-357` — `digits` parameter callback (no upper bound)\n- `src/OTP.php:283` — `$code % (10 ** $this-\u003egetDigits())`\n\n## Proof of concept\n\n```php\nuse OTPHP\\Factory;\nuse OTPHP\\InternalClock;\n\n$otp = Factory::loadFromProvisioningUri(\n    'otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50',\n    new InternalClock()\n);\n$otp-\u003eat(0); // DivisionByZeroError: Modulo by zero (escapes catch (\\Exception))\n```\n\n## Remediation\n\nEnforce a sane upper bound on `digits` in the parameter validation callback (e.g. reject values above 8–10, the practical range for OTPs) so that an out-of-range value is rejected with a documented exception instead of producing an object that fails later with an uncatchable `Error`.","severity":{"version":"4.0","score":8.7,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N","level":"high","from":"ghsa"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-1284","CWE-369"],"published":"2026-06-18T20:45:47Z","modified":"2026-09-10T03:50:49Z","fixes":[{"ecosystem":"Packagist","package":"spomky-labs/otphp","fixed_in":["11.4.3"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"Packagist","package":"spomky-labs/otphp","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"11.4.3"}]}]},{"source":"osv","ecosystem":"Packagist","package":"spomky-labs/otphp","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"11.4.3"}]}],"versions":["1.0.1-stable","11.0.3","11.1.0","11.1.1","11.2.0","11.2.1","11.2.2","11.3.0","11.4.0","11.4.1","11.4.2","5.0.0","v1.0.0-stable","v10.0.0","v10.0.1","v10.0.2","v10.0.3","v11.0.0","v11.0.1","v11.0.2","v2.0.0-stable","v2.0.1-stable","v2.0.2-stable","v3.0.0-stable","v3.0.1-stable","v3.1.0","v3.1.1","v4.0.0","v4.0.1","v4.0.2","v4.0.3","v4.0.4","v5.0.1","v6.0.0","v6.0.1","v6.0.2","v6.0.3","v6.0.4","v7.0.0","v7.0.1","v7.0.2","v7.0.3","v7.0.4","v8.0.0","v8.1.0","v8.2.0","v8.3.0","v8.3.1","v8.3.2","v8.3.3","v9.0.0","v9.0.0-alpha1","v9.0.1","v9.0.2","v9.0.3","v9.1.0","v9.1.1","v9.1.2","v9.1.3","v9.1.4"]}],"references":[{"url":"https://github.com/Spomky-Labs/otphp/security/advisories/GHSA-g7m4-839x-ch6v","tags":["web"]},{"url":"https://github.com/FriendsOfPHP/security-advisories/blob/master/spomky-labs/otphp/GHSA-g7m4-839x-ch6v.yaml","tags":["web"]},{"url":"https://github.com/advisories/GHSA-g7m4-839x-ch6v"},{"url":"https://github.com/Spomky-Labs/otphp","tags":["package"]}],"sources":{"ghsa":[{"id":"GHSA-g7m4-839x-ch6v","title":"spomky-labs/otphp: Unbounded digits parameter in a provisioning URI triggers an uncaught DivisionByZeroError in OTP generation","description":"## Summary\n\nThe `digits` parameter parsed from a provisioning URI is validated only with a lower bound (`$value \u003e 0`) and has no upper bound (`src/OTP.php:353-357`). OTP generation computes `$code % (10 ** $this-\u003egetDigits())` (`src/OTP.php:283`). When `digits` is large enough that `10 ** digits` overflows PHP's integer range and the `(int)` cast yields `0` (around `digits \u003e= 40` on 64-bit PHP 8.x), the modulo operand becomes `0` and PHP raises a `DivisionByZeroError`.\n\n## Impact\n\n`OTPHP\\Factory::loadFromProvisioningUri()` forwards the attacker-controlled `digits` query value to `setParameter('digits', $value)`, so a hostile URI such as `otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50` produces an OTP object whose `at()`, `now()`, and `verify()` all throw `DivisionByZeroError`. Because `DivisionByZeroError` extends `Error` (not `Exception`), callers that guard OTP generation with a `catch (\\Exception)` do not catch it, turning a malformed URI into an unhandled fatal error (denial of service of the verification path).\n\nMeasured threshold on PHP 8.3: `digits = 30` works, `digits \u003e= 40` throws `DivisionByZeroError: Modulo by zero`.\n\n## Affected component\n\n- `src/OTP.php:353-357` — `digits` parameter callback (no upper bound)\n- `src/OTP.php:283` — `$code % (10 ** $this-\u003egetDigits())`\n\n## Proof of concept\n\n```php\nuse OTPHP\\Factory;\nuse OTPHP\\InternalClock;\n\n$otp = Factory::loadFromProvisioningUri(\n    'otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50',\n    new InternalClock()\n);\n$otp-\u003eat(0); // DivisionByZeroError: Modulo by zero (escapes catch (\\Exception))\n```\n\n## Remediation\n\nEnforce a sane upper bound on `digits` in the parameter validation callback (e.g. reject values above 8–10, the practical range for OTPs) so that an out-of-range value is rejected with a documented exception instead of producing an object that fails later with an uncatchable `Error`.","status":"published","published":"2026-06-18T20:45:47Z","modified":"2026-06-18T20:45:48Z","cvss":[{"version":"4.0","score":8.7,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N","level":"high"}],"cwes":["CWE-369","CWE-1284"],"updated_at":"2026-11-02T14:40:30.53004Z"}],"osv":[{"id":"GHSA-g7m4-839x-ch6v","title":"spomky-labs/otphp: Unbounded digits parameter in a provisioning URI triggers an uncaught DivisionByZeroError in OTP generation","description":"## Summary\n\nThe `digits` parameter parsed from a provisioning URI is validated only with a lower bound (`$value \u003e 0`) and has no upper bound (`src/OTP.php:353-357`). OTP generation computes `$code % (10 ** $this-\u003egetDigits())` (`src/OTP.php:283`). When `digits` is large enough that `10 ** digits` overflows PHP's integer range and the `(int)` cast yields `0` (around `digits \u003e= 40` on 64-bit PHP 8.x), the modulo operand becomes `0` and PHP raises a `DivisionByZeroError`.\n\n## Impact\n\n`OTPHP\\Factory::loadFromProvisioningUri()` forwards the attacker-controlled `digits` query value to `setParameter('digits', $value)`, so a hostile URI such as `otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50` produces an OTP object whose `at()`, `now()`, and `verify()` all throw `DivisionByZeroError`. Because `DivisionByZeroError` extends `Error` (not `Exception`), callers that guard OTP generation with a `catch (\\Exception)` do not catch it, turning a malformed URI into an unhandled fatal error (denial of service of the verification path).\n\nMeasured threshold on PHP 8.3: `digits = 30` works, `digits \u003e= 40` throws `DivisionByZeroError: Modulo by zero`.\n\n## Affected component\n\n- `src/OTP.php:353-357` — `digits` parameter callback (no upper bound)\n- `src/OTP.php:283` — `$code % (10 ** $this-\u003egetDigits())`\n\n## Proof of concept\n\n```php\nuse OTPHP\\Factory;\nuse OTPHP\\InternalClock;\n\n$otp = Factory::loadFromProvisioningUri(\n    'otpauth://totp/Alice?secret=JBSWY3DPEHPK3PXP\u0026digits=50',\n    new InternalClock()\n);\n$otp-\u003eat(0); // DivisionByZeroError: Modulo by zero (escapes catch (\\Exception))\n```\n\n## Remediation\n\nEnforce a sane upper bound on `digits` in the parameter validation callback (e.g. reject values above 8–10, the practical range for OTPs) so that an out-of-range value is rejected with a documented exception instead of producing an object that fails later with an uncatchable `Error`.","status":"published","published":"2026-06-18T20:45:47Z","modified":"2026-09-10T03:50:49.441248787Z","cvss":[{"version":"4.0","score":8.7,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N","level":"high"}],"cwes":["CWE-1284","CWE-369"],"updated_at":"2026-11-02T15:46:47.472172Z"}]}}
