Skip to content

SEC-008: API Keys Stored in Plaintext Memory #76

Description

@s2x

| 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:

  1. API keys appear in PHP backtraces if exceptions are unhandled (property is visible in stack frames)
  2. API keys appear in crash dumps or var_dump()/print_r() output during debugging
  3. API keys persist in process memory for the lifetime of the object (potentially entire request)
  4. 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

  1. Application throws an unhandled exception — PHP outputs a backtrace showing constructor arguments, including $apiKey
  2. Attacker gains access to server crash dumps, debug logs, or error pages
  3. 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:

  1. 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.

  2. __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.

  3. 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

  1. In src/embeddings/EmbeddingInterfaces.php:72, change string $apiKey to ?string $apiKey = null in the constructor signature
  2. In src/embeddings/EmbeddingInterfaces.php:83, replace $this->apiKey = $apiKey; with env var fallback logic
  3. Add __debugInfo() method to ApiEmbeddingFunction
  4. Add __destruct() method to ApiEmbeddingFunction with sodium_memzero() call
  5. In src/embeddings/QwenDenseEmbedding.php:53, change string $apiKey to ?string $apiKey = null (backwards compatible)
  6. In src/embeddings/OpenAIDenseEmbedding.php:54, change string $apiKey to ?string $apiKey = null (backwards compatible)

Verification

  1. var_dump(new OpenAIDenseEmbedding('sk-test-key')) — output shows sk-***-key, not the full key
  2. var_dump(new OpenAIDenseEmbedding()) with OPENAI_API_KEY env var set — works, key from env
  3. var_dump(new OpenAIDenseEmbedding()) without env var — constructs with empty key, API calls will fail with auth error
  4. Run php -r 'echo sodium_memzero("test");' — verify sodium extension is available or handle gracefully

Acceptance Criteria

  • Unit tests: tests/test_embedding_apikey_mask.phptvar_dump(new OpenAIDenseEmbedding('sk-test-key')) — output shows sk-***-key, not full key; tests/test_embedding_apikey_env.phpt — construct with OPENAI_API_KEY env var, verify key loaded from environment; tests/test_embedding_apikey_destruct.phpt — create object, destroy, verify sodium_memzero() called (if sodium available)
  • Functional tests: 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)
  • Documentation: src/embeddings/EmbeddingInterfaces.php PHPDoc on ApiEmbeddingFunction — document __debugInfo() masking and sodium_memzero() destructor; src/embeddings/QwenDenseEmbedding.php and src/embeddings/OpenAIDenseEmbedding.php — update constructor PHPDoc for nullable $apiKey; SECURITY.md — document credential handling best practices for embedding functions
  • Changelog: CHANGELOG.md entry under ### Security — "Mask API keys in debug output via __debugInfo() and clear from memory via sodium_memzero() in destructor"

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions