From 4abaeeb0ae237e4b478b46a03f84f5ba729a1508 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Fri, 22 May 2026 09:36:04 -0500 Subject: [PATCH] fix(error-handler): coerce apiContext->key to public_key string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorHandler::handleException()/handleError() bound $this->apiContext->key directly into the KyteError.api_key string column. Api.php sets $this->key = new ModelObject(KyteAPIKey) — so mysqli_stmt::execute() threw "Object of class Kyte\Core\ModelObject could not be converted to string" *inside* the error handler. The secondary fatal swallowed the original error and surfaced to clients as a blank HTTP 500. Pre-existing ErrorHandler tests set context->key = null, missing the ModelObject path entirely. That's why the regression shipped in v4.4.0 and v4.4.1. Fix: extract the scalar public_key string for logging: 'api_key' => isset($this->apiContext->key->public_key) ? (string)$this->apiContext->key->public_key : null Falls back to null when ->key is unset or the ModelObject hasn't been retrieved. public_key is the audit-relevant value the column was designed to hold. Regression test ErrorHandlerSensitivityTest:: testApiContextKeyAsModelObjectLogsPublicKeyString exercises the real ModelObject path with a created KyteAPIKey fixture. All 182 tests pass. Patch-level fix. No schema changes. Composer update is sufficient. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 14 +++++++++ src/Exception/ErrorHandler.php | 13 ++++++-- tests/ErrorHandlerSensitivityTest.php | 43 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 862367d..da92acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 4.4.2 + +### Bug Fix: ErrorHandler crash when `apiContext->key` is a ModelObject + +The enhanced v4.4 ErrorHandler bound `$this->apiContext->key` directly into the `KyteError.api_key` string column. `$this->key` is set in `Api.php` as `new \Kyte\Core\ModelObject(KyteAPIKey)` — an object, not a string. `mysqli_stmt::execute()` then threw `Object of class Kyte\Core\ModelObject could not be converted to string` *inside* the error handler. That secondary fatal swallowed the original error and surfaced to clients as a **blank HTTP 500** with no log row written and no stack trace recoverable. + +Customer-visible impact: any request that triggered the error handler — including expected exceptions on routes like POST `/Session`, GET on a controller hitting a runtime error, etc. — returned 500 with no body. Production traffic at ETOM was affected from the v4.4.0 deploy onward. + +Fix: `'api_key' => isset($this->apiContext->key->public_key) ? (string)$this->apiContext->key->public_key : null` — extract the scalar `public_key` string (which is the audit-relevant value anyway). Falls back to `null` when the ModelObject hasn't been retrieved or when there's no key context at all. + +Regression test `ErrorHandlerSensitivityTest::testApiContextKeyAsModelObjectLogsPublicKeyString` exercises the previously-broken ModelObject path end to end. Pre-existing tests set `$context->key = null` and never hit the crash, which is why the regression slipped through 4.4.0 and 4.4.1. + +No schema changes. Composer upgrade is sufficient. + ## 4.4.1 ### Bug Fix: CORS preflight on `/jwt/*` endpoints diff --git a/src/Exception/ErrorHandler.php b/src/Exception/ErrorHandler.php index 5ad2d8d..6b7ec71 100644 --- a/src/Exception/ErrorHandler.php +++ b/src/Exception/ErrorHandler.php @@ -59,7 +59,14 @@ public function handleException($exception) { 'kyte_account' => isset($this->apiContext->account->id) ? $this->apiContext->account->id : null, 'user_id' => isset($this->apiContext->user->id) ? $this->apiContext->user->id : null, 'app_id' => isset($this->apiContext->appId) ? $this->apiContext->appId : null, - 'api_key' => isset($this->apiContext->key) ? $this->apiContext->key : null, + // $apiContext->key is a ModelObject(KyteAPIKey) — extract the + // scalar public_key string for logging. Binding the bare object + // would crash mysqli_stmt::execute() with + // "Object of class Kyte\Core\ModelObject could not be converted + // to string" because KyteError.api_key is a varchar column. + // That secondary fatal inside the error handler swallows the + // original error and surfaces as a blank HTTP 500 to the client. + 'api_key' => isset($this->apiContext->key->public_key) ? (string)$this->apiContext->key->public_key : null, // Additional details 'signature' => isset($this->apiContext->signature) ? $this->apiContext->signature : null, 'contentType' => isset($this->apiContext->contentType) ? $this->apiContext->contentType : null, @@ -133,7 +140,9 @@ public function handleError($errno, $errstr, $errfile, $errline) { 'kyte_account' => isset($this->apiContext->account->id) ? $this->apiContext->account->id : null, 'user_id' => isset($this->apiContext->user->id) ? $this->apiContext->user->id : null, 'app_id' => isset($this->apiContext->appId) ? $this->apiContext->appId : null, - 'api_key' => isset($this->apiContext->key) ? $this->apiContext->key : null, + // See handleException — $apiContext->key is a ModelObject; + // extract the scalar public_key string to avoid the same crash. + 'api_key' => isset($this->apiContext->key->public_key) ? (string)$this->apiContext->key->public_key : null, 'request' => isset($this->apiContext->request) ? $this->apiContext->request : null, 'model' => $modelName, 'message' => $errstr, diff --git a/tests/ErrorHandlerSensitivityTest.php b/tests/ErrorHandlerSensitivityTest.php index b4f5a2b..a221b46 100644 --- a/tests/ErrorHandlerSensitivityTest.php +++ b/tests/ErrorHandlerSensitivityTest.php @@ -189,6 +189,49 @@ public function testNoFlagsPreservesExistingBehavior(): void $this->assertStringContainsString('ok', $row['response']); } + /** + * Regression: $apiContext->key is a ModelObject(KyteAPIKey) in real + * Api flow, but the enhanced v4.4 ErrorHandler bound it directly into + * the KyteError.api_key string column. mysqli_stmt::execute() then + * threw "Object of class Kyte\Core\ModelObject could not be converted + * to string" — a secondary fatal that swallowed the original error + * and surfaced as a blank HTTP 500 to the client. + * + * Pre-existing tests set $context->key = null, so they didn't catch + * the regression. This test exercises the ModelObject path and + * asserts the api_key column gets the scalar public_key value. + */ + public function testApiContextKeyAsModelObjectLogsPublicKeyString(): void + { + \Kyte\Core\DBI::createTable(KyteAPIKey); + \Kyte\Core\DBI::query("DELETE FROM `KyteAPIKey` WHERE public_key = 'eh-key-test-pub-12345'"); + + $key = new \Kyte\Core\ModelObject(KyteAPIKey); + $key->create([ + 'identifier' => 'eh-key-test-iden', + 'public_key' => 'eh-key-test-pub-12345', + 'secret_key' => 'eh-key-test-sec', + 'epoch' => time(), + 'kyte_account' => $this->accountId, + ]); + + $context = $this->buildContext(self::PLAIN_MODEL); + $context->key = $key; // ← the real Api.php flow + $context->data = ['note' => 'whatever']; + $context->response = ['status' => 'ok']; + + $handler = ErrorHandler::getInstance($context); + // Without the fix, this throws ValueError/TypeError from mysqli + // and the error row is never written. + $handler->handleException(new \RuntimeException('regression check')); + + $row = $this->latestErrorRow(self::PLAIN_MODEL); + $this->assertNotNull($row, 'error row must be written even when context->key is a ModelObject'); + $this->assertSame('eh-key-test-pub-12345', $row['api_key'], + 'api_key column must hold the scalar public_key string, not a stringified ModelObject'); + $this->assertSame('regression check', $row['message'], 'original error message must survive'); + } + public function testAIDefenseInDepthGateBlocksSensitiveContext(): void { // Drive AIErrorCorrection::queueForAnalysis directly with a