{"id":"GHSA-g7m4-839x-ch6v","aliases":[],"title":"spomky-labs/otphp: Unbounded digits parameter in a provisioning URI triggers an uncaught DivisionByZeroError in OTP generation","titleSource":"OSV","descriptionSource":"OSV","declaredSeverity":"HIGH","description":"## Summary\n\nThe `digits` parameter parsed from a provisioning URI is validated only with a lower bound (`$value > 0`) and has no upper bound (`src/OTP.php:353-357`). OTP generation computes `$code % (10 ** $this->getDigits())` (`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 >= 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&digits=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 >= 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->getDigits())`\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&digits=50',\n    new InternalClock()\n);\n$otp->at(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":"high","score":8.7,"published":"2026-06-18T20:45:47.000Z","modified":"2026-09-10T03:50:49.441Z","withdrawn":false,"knownExploited":false,"affected":[{"ecosystem":"Packagist","name":"spomky-labs/otphp","sources":[{"name":"OSV · GHSA-g7m4-839x-ch6v","url":"https://osv.dev/vulnerability/GHSA-g7m4-839x-ch6v"}],"ranges":[{"events":[{"introduced":"0"},{"fixed":"11.4.3"}],"type":"ECOSYSTEM"}],"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"],"fixed":["11.4.3"]}],"sourceAffected":[],"references":["https://github.com/Spomky-Labs/otphp/security/advisories/GHSA-g7m4-839x-ch6v","https://github.com/FriendsOfPHP/security-advisories/blob/master/spomky-labs/otphp/GHSA-g7m4-839x-ch6v.yaml","https://github.com/Spomky-Labs/otphp"],"sources":[{"name":"OSV","id":"GHSA-g7m4-839x-ch6v","modified":"2026-09-10T03:50:49.441248787Z","url":"https://osv.dev/vulnerability/GHSA-g7m4-839x-ch6v"}],"assessments":[{"source":"OSV","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","score":8.7}],"products":[],"remediation":[],"kev":{"status":"unavailable","checkedAt":"2026-10-29T03:25:32.145Z"},"coverage":[{"source":"NVD","status":"not-checked"},{"source":"GHSA","status":"not-checked"},{"source":"EUVD","status":"not-checked"},{"source":"EPSS","status":"not-checked"}],"evidence":[]}