feat: bind explicitly authorized HTTP text extraction channel
This commit is contained in:
@@ -23,7 +23,7 @@ final class FollowupAudioPipeline
|
||||
{
|
||||
if (($this->provider['driver'] ?? '') !== 'asr_then_llm') { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
foreach (['asr', 'extraction'] as $stage) {
|
||||
if (!FollowupAudioProviderConfig::secureEndpoint($this->provider[$stage]['base_url'], $this->provider['allow_loopback_tunnel'])) {
|
||||
if (!FollowupAudioProviderConfig::stageEndpointAllowed($this->provider, $stage)) {
|
||||
throw new FollowupAudioException('HTTPS_REQUIRED');
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,10 @@ final class FollowupAudioPipeline
|
||||
: ($protocol === 'dify_chat' ? '/chat-messages' : '/chat/completions');
|
||||
$spec = ['url' => $part['base_url'] . $endpoint,
|
||||
'api_key' => $part['api_key'], 'timeout' => $timeout, 'stage' => $stage] + $payload;
|
||||
if ($stage === 'extraction' && isset($part['http_base_opt_in'])) {
|
||||
$spec['http_extraction_url'] = $part['http_base_opt_in'] . '/chat/completions';
|
||||
$spec['extraction_protocol'] = $protocol;
|
||||
}
|
||||
try {
|
||||
$response = $this->transport ? ($this->transport)($spec, $heartbeat)
|
||||
: (($this->provider['http_transport'] ?? 'curl') === 'openssl_stream'
|
||||
|
||||
@@ -75,8 +75,8 @@ final class FollowupAudioProviderConfig
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
|
||||
$secure = $provider['driver'] === 'asr_then_llm'
|
||||
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel'])
|
||||
&& self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
|
||||
? self::stageEndpointAllowed($provider, 'asr')
|
||||
&& self::stageEndpointAllowed($provider, 'extraction')
|
||||
: str_starts_with($provider['base_url'], 'https://');
|
||||
return $secure && preg_match('/^[a-f0-9]{64}$/D', $verified)
|
||||
&& hash_equals($provider['fingerprint'], $verified);
|
||||
@@ -91,7 +91,7 @@ final class FollowupAudioProviderConfig
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
$fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? '');
|
||||
$secure = $provider['driver'] === 'asr_then_llm'
|
||||
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel']) && self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
|
||||
? self::stageEndpointAllowed($provider, 'asr') && self::stageEndpointAllowed($provider, 'extraction')
|
||||
: str_starts_with($provider['base_url'], 'https://');
|
||||
return $secure && preg_match('/^[a-f0-9]{64}$/D', $fingerprint) && hash_equals($provider['fingerprint'], $fingerprint);
|
||||
}
|
||||
@@ -117,6 +117,24 @@ final class FollowupAudioProviderConfig
|
||||
&& in_array($parts['host'] ?? '', ['127.0.0.1', '[::1]'], true));
|
||||
}
|
||||
|
||||
/** A server-only exact HTTP text endpoint is an explicit exception, not verified TLS. */
|
||||
public static function stageEndpointAllowed(array $provider, string $stage): bool
|
||||
{
|
||||
if (!in_array($stage, ['asr', 'extraction'], true)) { return false; }
|
||||
$part = $provider[$stage] ?? [];
|
||||
$base = $part['base_url'] ?? '';
|
||||
if (!is_string($base)) { return false; }
|
||||
if (self::secureEndpoint($base, ($provider['allow_loopback_tunnel'] ?? false) === true)) { return true; }
|
||||
$pin = $part['http_base_opt_in'] ?? '';
|
||||
$url = parse_url($base);
|
||||
return $stage === 'extraction' && ($part['protocol'] ?? 'openai') === 'openai'
|
||||
&& is_string($pin) && $pin !== '' && hash_equals($pin, $base)
|
||||
&& is_array($url) && ($url['scheme'] ?? '') === 'http' && !empty($url['host'])
|
||||
&& !isset($url['user']) && !isset($url['pass']) && !isset($url['query']) && !isset($url['fragment'])
|
||||
&& !preg_match('/[\\x00-\\x20\\x7f\\\\%]|\\/(?:\\.{1,2})(?:\\/|$)/', $base)
|
||||
&& str_ends_with($base, '/v1');
|
||||
}
|
||||
|
||||
private static function pipeline(array $provider, array $settings, string $profile): array
|
||||
{
|
||||
$allowLoopback = ($provider['allow_loopback_tunnel'] ?? false) === true;
|
||||
@@ -143,10 +161,19 @@ final class FollowupAudioProviderConfig
|
||||
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
|
||||
if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); }
|
||||
}
|
||||
if (!self::secureEndpoint($base, $allowLoopback)) { throw new FollowupAudioException('HTTPS_REQUIRED'); }
|
||||
$resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']];
|
||||
// Default/explicit OpenAI retains its previous exact fingerprint representation.
|
||||
if ($protocol !== 'openai') { $resolved[$stage] += ['protocol' => $protocol, 'binding_revision' => $revision]; }
|
||||
$pin = $input['http_base_opt_in'] ?? '';
|
||||
if (!is_string($pin)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
if ($pin !== '') {
|
||||
if ($stage !== 'extraction' || $protocol !== 'openai' || $pin !== $base || !str_starts_with($base, 'http://')) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$resolved[$stage]['http_base_opt_in'] = $pin;
|
||||
$resolved[$stage]['http_policy'] = 'exact-text-endpoint-v1';
|
||||
}
|
||||
if (!self::stageEndpointAllowed($resolved, $stage)) { throw new FollowupAudioException('HTTPS_REQUIRED'); }
|
||||
}
|
||||
$chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
|
||||
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
|
||||
@@ -72,7 +72,13 @@ final class FollowupAudioStreamTransport
|
||||
$secure = ($parts['scheme'] ?? '') === 'https';
|
||||
$loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http'
|
||||
&& in_array($parts['host'], ['127.0.0.1', '[::1]'], true);
|
||||
if (!$secure && !$loopback) { return $result; }
|
||||
$httpExtraction = $stage === 'extraction' && ($parts['scheme'] ?? '') === 'http'
|
||||
&& ($spec['extraction_protocol'] ?? '') === 'openai'
|
||||
&& is_string($spec['http_extraction_url'] ?? null) && $spec['http_extraction_url'] !== ''
|
||||
&& hash_equals($spec['http_extraction_url'], $url) && str_ends_with($url, '/v1/chat/completions')
|
||||
&& !preg_match('/[%]|\\/(?:\\.{1,2})(?:\\/|$)/', $url)
|
||||
&& isset($spec['json_wire']) && !isset($spec['multipart']);
|
||||
if (!$secure && !$loopback && !$httpExtraction) { return $result; }
|
||||
$allowed = $stage === 'asr' ? ['/audio-to-text', '/audio/transcriptions'] : ['/chat-messages', '/chat/completions'];
|
||||
$matches = array_filter($allowed, static fn (string $suffix): bool => str_ends_with((string) ($parts['path'] ?? ''), $suffix));
|
||||
if ($matches === []) { return $result; }
|
||||
|
||||
Reference in New Issue
Block a user