diff --git a/server/.env.followup-audio.example b/server/.env.followup-audio.example index ef75e5c7b..a8f78f4ec 100644 --- a/server/.env.followup-audio.example +++ b/server/.env.followup-audio.example @@ -45,7 +45,7 @@ ASR_CHUNK_SECONDS = 120 ASR_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]. -# 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_ASR_BASE_URL = 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). # No automatic transport fallback; changing this requires new fingerprint readiness. 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 = diff --git a/server/app/common/service/followupaudio/FollowupAudioPipeline.php b/server/app/common/service/followupaudio/FollowupAudioPipeline.php index 1371327cb..436624fc0 100644 --- a/server/app/common/service/followupaudio/FollowupAudioPipeline.php +++ b/server/app/common/service/followupaudio/FollowupAudioPipeline.php @@ -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' diff --git a/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php b/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php index 7d64b0d0b..ecfb4c61f 100644 --- a/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php +++ b/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php @@ -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'); } diff --git a/server/app/common/service/followupaudio/FollowupAudioStreamTransport.php b/server/app/common/service/followupaudio/FollowupAudioStreamTransport.php index 7a2bb4837..7f5fb0f01 100644 --- a/server/app/common/service/followupaudio/FollowupAudioStreamTransport.php +++ b/server/app/common/service/followupaudio/FollowupAudioStreamTransport.php @@ -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; } diff --git a/server/config/followup_audio.php b/server/config/followup_audio.php index 473fa2f52..b946d98e3 100644 --- a/server/config/followup_audio.php +++ b/server/config/followup_audio.php @@ -59,6 +59,8 @@ return [ ], 'extraction' => [ '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', ''), 'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''), 'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''), diff --git a/server/tests/FollowupAudioHttpExtractionTest.php b/server/tests/FollowupAudioHttpExtractionTest.php new file mode 100644 index 000000000..3aa394157 --- /dev/null +++ b/server/tests/FollowupAudioHttpExtractionTest.php @@ -0,0 +1,32 @@ +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','['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);}