feat: bind explicitly authorized HTTP text extraction channel
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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);}
|
||||||
Reference in New Issue
Block a user