| Severity | Medium |
| File | src/embeddings/ApiEmbeddingFunction.php:72 |
| Impact | API key leakage through crash dumps, error logs, or debug output |
| CVSS | 4.4 (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N) |
| Status | Open |
Problem
API keys are stored as plain string properties in ApiEmbeddingFunction (the base class for QwenDenseEmbedding and OpenAIDenseEmbedding):
protected string $apiKey;
These keys are then included directly in HTTP headers returned by subclasses:
// QwenDenseEmbedding.php:71
return ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'];
// OpenAIDenseEmbedding.php:73
return ['Authorization: Bearer ' . $this->apiKey, 'Content-Type: application/json'];
Risks:
- API keys appear in PHP backtraces if exceptions are unhandled (property is visible in stack frames)
- API keys appear in crash dumps or
var_dump()/print_r() output during debugging
- API keys persist in process memory for the lifetime of the object (potentially entire request)
- cURL error messages may include request headers in verbose mode
Affected Code
// src/embeddings/EmbeddingInterfaces.php:72
abstract class ApiEmbeddingFunction
{
protected string $apiKey;
public function __construct(
string $apiKey,
?string $baseUrl = null,
int $timeout = 30,
?string $proxy = null
) {
$this->apiKey = $apiKey; // Stored in plaintext for object lifetime
// ...
}
}
// src/embeddings/QwenDenseEmbedding.php:52
public function __construct(
string $apiKey,
string $model = self::MODEL_V4,
// ...
) {
parent::__construct($apiKey, $baseUrl, $timeout, $proxy); // Key in memory
// ...
}
Attack Scenario
- Application throws an unhandled exception — PHP outputs a backtrace showing constructor arguments, including
$apiKey
- Attacker gains access to server crash dumps, debug logs, or error pages
- Attacker extracts the API key and uses it to make API calls on the application's behalf
Solution
Apply three complementary mitigations:
// src/embeddings/EmbeddingInterfaces.php — revised ApiEmbeddingFunction
abstract class ApiEmbeddingFunction
{
protected string $apiKey;
public function __construct(
?string $apiKey = null,
?string $baseUrl = null,
int $timeout = 30,
?string $proxy = null
) {
$this->apiKey = $apiKey ?? getenv('OPENAI_API_KEY') ?? getenv('DASHSCOPE_API_KEY') ?? '';
$this->baseUrl = $baseUrl ?? $this->getDefaultBaseUrl();
$this->timeout = $timeout;
$this->proxy = $proxy;
}
public function __debugInfo(): array
{
return [
'apiKey' => '***' . substr($this->apiKey, -4),
'baseUrl' => $this->baseUrl,
'timeout' => $this->timeout,
];
}
public function __destruct()
{
if (function_exists('sodium_memzero')) {
sodium_memzero($this->apiKey);
}
}
}
Rationale
All three mitigations are complementary and should be applied together:
-
Environment variable fallback (__construct change): Allows keys to be read from environment variables (OPENAI_API_KEY, DASHSCOPE_API_KEY) instead of being passed as constructor arguments. Keys passed as arguments still work (backwards compatible), but this encourages the more secure env-var pattern. Constructor parameter is now ?string (nullable) — the key must come from somewhere.
-
__debugInfo() masking: PHP's __debugInfo() method controls what var_dump() outputs. The key is truncated to only the last 4 characters, preventing accidental exposure in logs and debug output while still allowing identification.
-
sodium_memzero() in destructor: When the sodium extension is available, sodium_memzero() overwrites the string in memory. Even though PHP strings are copy-on-write, this at least clears the original property. If sodium is not available, this is a no-op.
Implementation Steps
- In
src/embeddings/EmbeddingInterfaces.php:72, change string $apiKey to ?string $apiKey = null in the constructor signature
- In
src/embeddings/EmbeddingInterfaces.php:83, replace $this->apiKey = $apiKey; with env var fallback logic
- Add
__debugInfo() method to ApiEmbeddingFunction
- Add
__destruct() method to ApiEmbeddingFunction with sodium_memzero() call
- In
src/embeddings/QwenDenseEmbedding.php:53, change string $apiKey to ?string $apiKey = null (backwards compatible)
- In
src/embeddings/OpenAIDenseEmbedding.php:54, change string $apiKey to ?string $apiKey = null (backwards compatible)
Verification
var_dump(new OpenAIDenseEmbedding('sk-test-key')) — output shows sk-***-key, not the full key
var_dump(new OpenAIDenseEmbedding()) with OPENAI_API_KEY env var set — works, key from env
var_dump(new OpenAIDenseEmbedding()) without env var — constructs with empty key, API calls will fail with auth error
- Run
php -r 'echo sodium_memzero("test");' — verify sodium extension is available or handle gracefully
Acceptance Criteria
| Severity | Medium |
| File |
src/embeddings/ApiEmbeddingFunction.php:72|| Impact | API key leakage through crash dumps, error logs, or debug output |
| CVSS | 4.4 (AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N) |
| Status | Open |
Problem
API keys are stored as plain string properties in
ApiEmbeddingFunction(the base class forQwenDenseEmbeddingandOpenAIDenseEmbedding):These keys are then included directly in HTTP headers returned by subclasses:
Risks:
var_dump()/print_r()output during debuggingAffected Code
Attack Scenario
$apiKeySolution
Apply three complementary mitigations:
Rationale
All three mitigations are complementary and should be applied together:
Environment variable fallback (
__constructchange): Allows keys to be read from environment variables (OPENAI_API_KEY,DASHSCOPE_API_KEY) instead of being passed as constructor arguments. Keys passed as arguments still work (backwards compatible), but this encourages the more secure env-var pattern. Constructor parameter is now?string(nullable) — the key must come from somewhere.__debugInfo()masking: PHP's__debugInfo()method controls whatvar_dump()outputs. The key is truncated to only the last 4 characters, preventing accidental exposure in logs and debug output while still allowing identification.sodium_memzero()in destructor: When thesodiumextension is available,sodium_memzero()overwrites the string in memory. Even though PHP strings are copy-on-write, this at least clears the original property. Ifsodiumis not available, this is a no-op.Implementation Steps
src/embeddings/EmbeddingInterfaces.php:72, changestring $apiKeyto?string $apiKey = nullin the constructor signaturesrc/embeddings/EmbeddingInterfaces.php:83, replace$this->apiKey = $apiKey;with env var fallback logic__debugInfo()method toApiEmbeddingFunction__destruct()method toApiEmbeddingFunctionwithsodium_memzero()callsrc/embeddings/QwenDenseEmbedding.php:53, changestring $apiKeyto?string $apiKey = null(backwards compatible)src/embeddings/OpenAIDenseEmbedding.php:54, changestring $apiKeyto?string $apiKey = null(backwards compatible)Verification
var_dump(new OpenAIDenseEmbedding('sk-test-key'))— output showssk-***-key, not the full keyvar_dump(new OpenAIDenseEmbedding())withOPENAI_API_KEYenv var set — works, key from envvar_dump(new OpenAIDenseEmbedding())without env var — constructs with empty key, API calls will fail with auth errorphp -r 'echo sodium_memzero("test");'— verify sodium extension is available or handle gracefullyAcceptance Criteria
tests/test_embedding_apikey_mask.phpt—var_dump(new OpenAIDenseEmbedding('sk-test-key'))— output showssk-***-key, not full key;tests/test_embedding_apikey_env.phpt— construct withOPENAI_API_KEYenv var, verify key loaded from environment;tests/test_embedding_apikey_destruct.phpt— create object, destroy, verifysodium_memzero()called (if sodium available)tests/test_embedding_apikey_backtrace.phpt— trigger exception in embedding function, verify backtrace does not contain the full API key;tests/test_embedding_apikey_memzero.phpt— with sodium extension, verify string is overwritten after destruct (via memory inspection or side-channel)src/embeddings/EmbeddingInterfaces.phpPHPDoc onApiEmbeddingFunction— document__debugInfo()masking andsodium_memzero()destructor;src/embeddings/QwenDenseEmbedding.phpandsrc/embeddings/OpenAIDenseEmbedding.php— update constructor PHPDoc for nullable$apiKey;SECURITY.md— document credential handling best practices for embedding functionsCHANGELOG.mdentry under### Security— "Mask API keys in debug output via __debugInfo() and clear from memory via sodium_memzero() in destructor"