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
+9 -1
View File
@@ -45,7 +45,7 @@ ASR_CHUNK_SECONDS = 120
ASR_REQUEST_TIMEOUT = 240 ASR_REQUEST_TIMEOUT = 240
EXTRACTION_REQUEST_TIMEOUT = 240 EXTRACTION_REQUEST_TIMEOUT = 240
# HTTPS required. Enable this only for an operator-established SSH tunnel on literal 127.0.0.1 or [::1]. # HTTPS required. Enable this only for an operator-established SSH tunnel on literal 127.0.0.1 or [::1].
# localhost, other hosts, external HTTP and HTTP redirects are never exceptions. # This loopback switch never permits other hosts; redirects remain disabled.
QWEN_ALLOW_LOOPBACK_TUNNEL = false QWEN_ALLOW_LOOPBACK_TUNNEL = false
QWEN_ASR_BASE_URL = QWEN_ASR_BASE_URL =
QWEN_ASR_API_KEY = QWEN_ASR_API_KEY =
@@ -93,3 +93,11 @@ OPENAI_EXTRACTION_BINDING_REVISION =
# asr_then_llm only: curl (default) or openssl_stream (explicit verified-TLS child process). # asr_then_llm only: curl (default) or openssl_stream (explicit verified-TLS child process).
# No automatic transport fallback; changing this requires new fingerprint readiness. # No automatic transport fallback; changing this requires new fingerprint readiness.
HTTP_TRANSPORT = curl HTTP_TRANSPORT = curl
# Explicit operator authorization for a fixed plaintext text-extraction channel only.
# Copy the exact normalized http://HOST[/path]/v1 base here; no wildcards, encoded/dot paths or redirects.
# Only asr_then_llm + extraction protocol=openai accepts it. ASR and all HTTPS verification stay unchanged.
# HTTP carries transcript and API key without TLS. Leave empty unless that channel was expressly authorized.
# Changes bind a new provider fingerprint; preserve old profiles/tasks rather than replacing their identity.
QWEN_EXTRACTION_HTTP_BASE_OPT_IN =
OPENAI_EXTRACTION_HTTP_BASE_OPT_IN =
@@ -23,7 +23,7 @@ final class FollowupAudioPipeline
{ {
if (($this->provider['driver'] ?? '') !== 'asr_then_llm') { throw new FollowupAudioException('CONFIG_INVALID'); } if (($this->provider['driver'] ?? '') !== 'asr_then_llm') { throw new FollowupAudioException('CONFIG_INVALID'); }
foreach (['asr', 'extraction'] as $stage) { 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'); throw new FollowupAudioException('HTTPS_REQUIRED');
} }
} }
@@ -301,6 +301,10 @@ final class FollowupAudioPipeline
: ($protocol === 'dify_chat' ? '/chat-messages' : '/chat/completions'); : ($protocol === 'dify_chat' ? '/chat-messages' : '/chat/completions');
$spec = ['url' => $part['base_url'] . $endpoint, $spec = ['url' => $part['base_url'] . $endpoint,
'api_key' => $part['api_key'], 'timeout' => $timeout, 'stage' => $stage] + $payload; '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 { try {
$response = $this->transport ? ($this->transport)($spec, $heartbeat) $response = $this->transport ? ($this->transport)($spec, $heartbeat)
: (($this->provider['http_transport'] ?? 'curl') === 'openssl_stream' : (($this->provider['http_transport'] ?? 'curl') === 'openssl_stream'
@@ -75,8 +75,8 @@ final class FollowupAudioProviderConfig
catch (FollowupAudioException $error) { return false; } catch (FollowupAudioException $error) { return false; }
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? ''); $verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
$secure = $provider['driver'] === 'asr_then_llm' $secure = $provider['driver'] === 'asr_then_llm'
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel']) ? self::stageEndpointAllowed($provider, 'asr')
&& self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel']) && self::stageEndpointAllowed($provider, 'extraction')
: str_starts_with($provider['base_url'], 'https://'); : str_starts_with($provider['base_url'], 'https://');
return $secure && preg_match('/^[a-f0-9]{64}$/D', $verified) return $secure && preg_match('/^[a-f0-9]{64}$/D', $verified)
&& hash_equals($provider['fingerprint'], $verified); && hash_equals($provider['fingerprint'], $verified);
@@ -91,7 +91,7 @@ final class FollowupAudioProviderConfig
catch (FollowupAudioException $error) { return false; } catch (FollowupAudioException $error) { return false; }
$fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? ''); $fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? '');
$secure = $provider['driver'] === 'asr_then_llm' $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://'); : str_starts_with($provider['base_url'], 'https://');
return $secure && preg_match('/^[a-f0-9]{64}$/D', $fingerprint) && hash_equals($provider['fingerprint'], $fingerprint); 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)); && 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 private static function pipeline(array $provider, array $settings, string $profile): array
{ {
$allowLoopback = ($provider['allow_loopback_tunnel'] ?? false) === true; $allowLoopback = ($provider['allow_loopback_tunnel'] ?? false) === true;
@@ -143,10 +161,19 @@ final class FollowupAudioProviderConfig
$original = rtrim((string) ($input['base_url'] ?? ''), '/'); $original = rtrim((string) ($input['base_url'] ?? ''), '/');
if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); } 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']]; $resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']];
// Default/explicit OpenAI retains its previous exact fingerprint representation. // Default/explicit OpenAI retains its previous exact fingerprint representation.
if ($protocol !== 'openai') { $resolved[$stage] += ['protocol' => $protocol, 'binding_revision' => $revision]; } 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); $chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); } if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
@@ -72,7 +72,13 @@ final class FollowupAudioStreamTransport
$secure = ($parts['scheme'] ?? '') === 'https'; $secure = ($parts['scheme'] ?? '') === 'https';
$loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http' $loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http'
&& in_array($parts['host'], ['127.0.0.1', '[::1]'], true); && 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']; $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)); $matches = array_filter($allowed, static fn (string $suffix): bool => str_ends_with((string) ($parts['path'] ?? ''), $suffix));
if ($matches === []) { return $result; } if ($matches === []) { return $result; }
+2
View File
@@ -59,6 +59,8 @@ return [
], ],
'extraction' => [ 'extraction' => [
'protocol' => (string) env($prefix . 'EXTRACTION_PROTOCOL', 'openai'), 'protocol' => (string) env($prefix . 'EXTRACTION_PROTOCOL', 'openai'),
// Empty by default; operator-authorized exact HTTP text endpoint only, never ASR.
'http_base_opt_in' => (string) env($prefix . 'EXTRACTION_HTTP_BASE_OPT_IN', ''),
'binding_revision' => (string) env($prefix . 'EXTRACTION_BINDING_REVISION', ''), 'binding_revision' => (string) env($prefix . 'EXTRACTION_BINDING_REVISION', ''),
'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''), 'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''),
'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''), 'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''),
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioProviderConfig as P;
use app\common\service\followupaudio\FollowupAudioException as E;
use app\common\service\followupaudio\FollowupAudioStreamTransport as T;
new think\App();$config=new think\Config();think\Container::getInstance()->instance('config',$config);
$n=0;$ok=static function(bool $value,string $why)use(&$n){if(!$value)throw new RuntimeException($why);$n++;};
$reject=static function(callable $f)use($ok){try{$f();}catch(E $e){$ok(in_array($e->errorCode,['HTTPS_REQUIRED','CONFIG_INVALID'],true),'bounded configuration rejection');return;}throw new RuntimeException('Expected reject');};
$slot=['driver'=>'asr_then_llm','label'=>'Synthetic GPT','asr'=>['protocol'=>'dify','binding_revision'=>'test-v1','base_url'=>'https://asr.invalid/v1','api_key'=>'synthetic-asr','model'=>'synthetic-asr'],'extraction'=>['protocol'=>'openai','base_url'=>'http://text.invalid/v1','api_key'=>'synthetic-key','model'=>'synthetic-llm','http_base_opt_in'=>'http://text.invalid/v1']];
$s=['providers'=>['openai'=>$slot],'http_transport'=>'openssl_stream'];
$p=P::resolve('openai',$s,[]);$ok(P::stageEndpointAllowed($p,'extraction'),'exact authorized text HTTP accepted');$ok(P::stageEndpointAllowed($p,'asr'),'ASR remains HTTPS');
$ok($p['extraction']['http_policy']==='exact-text-endpoint-v1','versioned opt-in included in fingerprint input');
foreach(['','http://other.invalid/v1','http://text.invalid:81/v1','http://text.invalid/other/v1','http://text.invalid/v1/','http://text.invalid/v1/chat/completions',true,[]]as$pin){$x=$s;$x['providers']['openai']['extraction']['http_base_opt_in']=$pin;$reject(fn()=>P::resolve('openai',$x,[]));}
foreach(['http://text.invalid.evil/v1','http://text.invalid/v1?x=1','http://u:p@text.invalid/v1','http://text.invalid/a/../v1','http://text.invalid/%2e/v1',"http://text.invalid/v1\r\nX: 1",'ftp://text.invalid/v1']as$url){$x=$s;$x['providers']['openai']['extraction']['base_url']=$url;$x['providers']['openai']['extraction']['http_base_opt_in']=str_contains($url,'invalid.evil')?'http://text.invalid/v1':$url;$reject(fn()=>P::resolve('openai',$x,[]));}
$x=$s;$x['providers']['openai']['asr']['base_url']='http://text.invalid/v1';$reject(fn()=>P::resolve('openai',$x,[]));
$x['providers']['openai']['asr']['http_base_opt_in']='http://text.invalid/v1';$reject(fn()=>P::resolve('openai',$x,[]));
$x=$s;$x['providers']['openai']['extraction']['protocol']='dify_chat';$x['providers']['openai']['extraction']['binding_revision']='test';$reject(fn()=>P::resolve('openai',$x,[]));
$x=$s;$x['providers']['qwen']=$slot;unset($x['providers']['qwen']['extraction']['http_base_opt_in']);$reject(fn()=>P::resolve('qwen',$x,[]));
$legacy=$slot;$legacy['extraction']['base_url']='https://text.invalid/v1';unset($legacy['extraction']['http_base_opt_in']);$old=['providers'=>['qwen'=>$legacy],'http_transport'=>'openssl_stream'];$oldHash=P::resolve('qwen',$old,[])['fingerprint'];$old['providers']['qwen']['extraction']['http_base_opt_in']='';$ok(P::resolve('qwen',$old,[])['fingerprint']===$oldHash,'empty opt-in does not change legacy hash');$old['providers']['openai']=$slot;$ok(P::resolve('qwen',$old,[])['fingerprint']===$oldHash,'new slot never rebinds old slot');$ok($p['fingerprint']!==$oldHash,'new HTTP identity differs');
$s['providers']['openai']['preview_verified_fingerprint']=$p['fingerprint'];$ok(P::previewVerified('openai',$s,[]),'explicit pin still needs matching preview fingerprint');$s['providers']['openai']['preview_verified_fingerprint']='';$ok(!P::previewVerified('openai',$s,[]),'no readiness invented');$ok(!P::secureEndpoint('http://text.invalid/v1',false),'global TLS primitive unchanged');
$dir=sys_get_temp_dir().'/fa-http-extraction-'.bin2hex(random_bytes(6));mkdir($dir,0700);$sock=stream_socket_server('tcp://127.0.0.1:0',$eno,$err);$port=(int)substr(strrchr(stream_socket_get_name($sock,false),':'),1);fclose($sock);
file_put_contents($dir.'/router.php','<?php file_put_contents(getenv("LOG"),$_SERVER["REQUEST_URI"]."\n",FILE_APPEND); if(isset($_SERVER["HTTP_X_NEVER"]))exit; $v=json_decode(file_get_contents("php://input"),true); if(($v["redirect"]??false)){header("Location: /stolen/v1/chat/completions",true,302);echo "redirect";return;} header("Content-Type: application/json");echo "{\"ok\":true}";');
$proc=proc_open([PHP_BINARY,'-n','-S','127.0.0.1:'.$port,$dir.'/router.php'],[0=>['pipe','r'],1=>['file',$dir.'/stdout','a'],2=>['file',$dir.'/stderr','a']],$pipes,null,array_merge(getenv(),['LOG'=>$dir.'/requests']));fclose($pipes[0]);
try{
for($i=0;$i<100;$i++){$sock=@stream_socket_client('tcp://127.0.0.1:'.$port,$eno,$err,.1);if($sock){fclose($sock);break;}usleep(20000);}
$url='http://127.0.0.1:'.$port.'/v1/chat/completions';$spec=['url'=>$url,'stage'=>'extraction','timeout'=>3,'api_key'=>'synthetic-key','json'=>['model'=>'synthetic'],'http_extraction_url'=>$url,'extraction_protocol'=>'openai'];
$reply=T::request($spec,fn()=>true,1024,false);$ok($reply['http_code']===200&&$reply['errno']===0&&$reply['body']==='{"ok":true}','real standalone stream child exact HTTP request without loopback bypass');
foreach(['missing','other-url','asr','dify','multipart']as$kind){$v=$spec;if($kind==='missing')unset($v['http_extraction_url']);if($kind==='other-url')$v['http_extraction_url']=$url.'/other';if($kind==='asr')$v['stage']='asr';if($kind==='dify')$v['extraction_protocol']='dify_chat';if($kind==='multipart')$v['multipart']=[];$before=file_get_contents($dir.'/requests');$reply=T::request($v,fn()=>true,1024,false);$ok($reply['errno']===43&&file_get_contents($dir.'/requests')===$before,'child rejects ' . $kind);}
$spec['json']['redirect']=true;$reply=T::request($spec,fn()=>true,1024,false);$ok($reply['http_code']===302&&!str_contains(file_get_contents($dir.'/requests'),'/stolen/'),'redirect never followed');
echo 'FOLLOWUP_AUDIO_HTTP_EXTRACTION assertions='.$n.' PASS exact_pin=1 stage_isolation=1 legacy_fingerprint=1 child_transport=1 no_redirect=1'.PHP_EOL;
}finally{proc_terminate($proc,9);proc_close($proc);foreach(glob($dir.'/*')as$f)unlink($f);rmdir($dir);}