feat: bind explicitly authorized HTTP text extraction channel

This commit is contained in:
2026-10-10 10:28:28 +08:00
parent 73be0ecedf
commit 228d93ac3b
6 changed files with 86 additions and 7 deletions
@@ -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'); }