diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e97058..475a75d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- **SEC-012: Enforce explicit SSL certificate verification in embedding API requests** (#80) + - Added `CURLOPT_SSL_VERIFYPEER => true` and `CURLOPT_SSL_VERIFYHOST => 2` to all embedding HTTP requests + - Using `curl_setopt_array()` to ensure SSL options are always applied together + - Proxy configuration remains as a separate `curl_setopt()` call (unchanged) + - Added static analysis test `test_embedding_ssl_verify.phpt` to verify SSL options are present in source + ### Added - **TEST-001: ZVecException isolation tests for error code strings, constructor, chaining, and error details** (#99) diff --git a/src/embeddings/EmbeddingInterfaces.php b/src/embeddings/EmbeddingInterfaces.php index 4e663c7..7c4ecb3 100644 --- a/src/embeddings/EmbeddingInterfaces.php +++ b/src/embeddings/EmbeddingInterfaces.php @@ -105,11 +105,18 @@ protected function post(string $endpoint, array $data): array $url = rtrim($this->baseUrl, '/') . '/' . ltrim($endpoint, '/'); $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout); - curl_setopt($ch, CURLOPT_HTTPHEADER, $this->getHeaders()); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => $this->getHeaders(), + // Explicit SSL verification — never trust silently even if global + // PHP curl options are changed (e.g., curl.cainfo="") or CA bundle + // is not configured. See SEC-012. + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]); if ($this->proxy !== null) { curl_setopt($ch, CURLOPT_PROXY, $this->proxy); diff --git a/tests/test_embedding_ssl_verify.php b/tests/test_embedding_ssl_verify.php new file mode 100644 index 0000000..70d571a --- /dev/null +++ b/tests/test_embedding_ssl_verify.php @@ -0,0 +1,54 @@ +\s*true/', $source); +$hostMatch = preg_match('/CURLOPT_SSL_VERIFYHOST\s*=>\s*2/', $source); + +echo "SSL_VERIFYPEER set to true: " . ($peerMatch ? 'yes' : 'no') . "\n"; +echo "SSL_VERIFYHOST set to 2: " . ($hostMatch ? 'yes' : 'no') . "\n"; + +// Verify options are in the post() method specifically (not just anywhere) +$postMethodStart = strpos($source, 'protected function post('); +$postMethodEnd = strpos($source, 'abstract protected function getHeaders('); +$postSource = substr($source, $postMethodStart, $postMethodEnd - $postMethodStart); + +$postHasPeer = str_contains($postSource, 'CURLOPT_SSL_VERIFYPEER'); +$postHasHost = str_contains($postSource, 'CURLOPT_SSL_VERIFYHOST'); + +echo "SSL options in post() method: " . ($postHasPeer && $postHasHost ? 'yes' : 'no') . "\n"; + +// Verify options are set before the proxy check (correct ordering) +$peerPos = strpos($postSource, 'CURLOPT_SSL_VERIFYPEER'); +$proxyPos = strpos($postSource, 'CURLOPT_PROXY'); +$correctOrder = $peerPos < $proxyPos; + +echo "SSL options before proxy: " . ($correctOrder ? 'yes' : 'no') . "\n"; + +// Verify proxy uses separate curl_setopt (not in the array) +$proxyInArray = preg_match('/CURLOPT_PROXY\s*=>/', $postSource); +echo "Proxy NOT in curl_setopt_array: " . ($proxyInArray ? 'no' : 'yes') . "\n"; + +$allPass = $hasSslVerifyPeer && $hasSslVerifyHost && $hasCurlSetoptArray + && $peerMatch && $hostMatch + && $postHasPeer && $postHasHost + && $correctOrder && !$proxyInArray; + +echo ($allPass ? "PASS" : "FAIL") . "\n"; diff --git a/tests/test_embedding_ssl_verify.phpt b/tests/test_embedding_ssl_verify.phpt new file mode 100644 index 0000000..6a335c8 --- /dev/null +++ b/tests/test_embedding_ssl_verify.phpt @@ -0,0 +1,49 @@ +--TEST-- +SEC-012: Explicit SSL verification in embedding API requests +--FILE-- +\s*true/'; +$patternHost = '/CURLOPT_SSL_VERIFYHOST\s*=>\s*2/'; + +if (!preg_match($patternPeer, $source)) { + echo "FAIL: CURLOPT_SSL_VERIFYPEER is not set to true\n"; + exit(1); +} +if (!preg_match($patternHost, $source)) { + echo "FAIL: CURLOPT_SSL_VERIFYHOST is not set to 2\n"; + exit(1); +} + +echo "PASS: CURLOPT_SSL_VERIFYPEER => true and CURLOPT_SSL_VERIFYHOST => 2\n"; + +// Verify curl_setopt_array is used (not individual curl_setopt calls for SSL) +if (strpos($source, 'curl_setopt_array') === false) { + echo "FAIL: curl_setopt_array not used (should use array form for SSL options)\n"; + exit(1); +} +echo "PASS: curl_setopt_array is used for setting curl options\n"; +?> +--EXPECT-- +PASS: CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST are explicitly set +PASS: CURLOPT_SSL_VERIFYPEER => true and CURLOPT_SSL_VERIFYHOST => 2 +PASS: curl_setopt_array is used for setting curl options