From 1fb3070511a1b782c6bfacfe2d8f2a34c02698b9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 21:49:45 +0300 Subject: [PATCH] feat(sandbox): Zero-Trust credentials injector over Stdio JSON-RPC Forbid secrets on process argv/env, load them from ConnectionSecretsStore, and inject via system.injectCredentials / db.connect with wipeable buffers (issue #302). --- .../extensions/rpc/json_rpc_stdio_client.dart | 142 +++++++ .../sandbox/sandbox_credentials_injector.dart | 141 +++++++ .../sandbox/sandbox_process_runner.dart | 9 + .../sandbox/sandbox_secret_guard.dart | 76 ++++ .../sandbox_credentials_injector_test.dart | 356 ++++++++++++++++++ 5 files changed, 724 insertions(+) create mode 100644 lib/core/extensions/rpc/json_rpc_stdio_client.dart create mode 100644 lib/core/extensions/sandbox/sandbox_credentials_injector.dart create mode 100644 lib/core/extensions/sandbox/sandbox_secret_guard.dart create mode 100644 test/core/extensions/sandbox/sandbox_credentials_injector_test.dart diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart new file mode 100644 index 00000000..b3f04243 --- /dev/null +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Minimal JSON-RPC 2.0 client over newline-delimited JSON on stdio. +/// +/// Enough for Block E credential injection and later Block C methods without +/// pulling `json_rpc_2` yet. One JSON object per line on stdin/stdout. +class JsonRpcStdioClient { + JsonRpcStdioClient({ + required Stream> stdout, + required IOSink stdin, + this.requestTimeout = const Duration(seconds: 10), + }) : _stdin = stdin, + _lines = utf8.decoder.bind(stdout).transform(const LineSplitter()) { + _subscription = _lines.listen(_onLine, onError: _onError, onDone: _onDone); + } + + final IOSink _stdin; + final Stream _lines; + final Duration requestTimeout; + + final Map> _pending = {}; + var _nextId = 1; + var _closed = false; + StreamSubscription? _subscription; + Object? _fatalError; + + /// Sends a JSON-RPC request and waits for the matching response. + Future sendRequest( + String method, [ + Object? params, + ]) async { + if (_closed) { + throw StateError('JsonRpcStdioClient is closed'); + } + if (_fatalError != null) { + throw StateError('JsonRpcStdioClient failed: $_fatalError'); + } + + final id = _nextId++; + final completer = Completer(); + _pending[id] = completer; + + final payload = { + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + if (params != null) 'params': params, + }; + + _stdin.writeln(jsonEncode(payload)); + await _stdin.flush(); + + try { + return await completer.future.timeout(requestTimeout); + } on TimeoutException { + _pending.remove(id); + throw TimeoutException( + 'JSON-RPC request "$method" timed out after $requestTimeout', + ); + } + } + + Future close() async { + if (_closed) return; + _closed = true; + await _subscription?.cancel(); + _subscription = null; + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(StateError('JsonRpcStdioClient closed')); + } + } + _pending.clear(); + } + + void _onLine(String line) { + if (line.trim().isEmpty) return; + late final Map message; + try { + final decoded = jsonDecode(line); + if (decoded is! Map) return; + message = decoded; + } catch (_) { + return; + } + + final id = message['id']; + if (id is! int) return; + final completer = _pending.remove(id); + if (completer == null || completer.isCompleted) return; + + if (message.containsKey('error')) { + final error = message['error']; + completer.completeError( + JsonRpcException.fromJson(error is Map ? error : {'message': '$error'}), + ); + return; + } + completer.complete(message['result']); + } + + void _onError(Object error, StackTrace stackTrace) { + _fatalError = error; + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(error, stackTrace); + } + } + _pending.clear(); + } + + void _onDone() { + _fatalError ??= StateError('Plugin stdout closed'); + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(_fatalError!); + } + } + _pending.clear(); + } +} + +class JsonRpcException implements Exception { + JsonRpcException({this.code, required this.message, this.data}); + + factory JsonRpcException.fromJson(Map error) { + return JsonRpcException( + code: error['code'] is int ? error['code'] as int : null, + message: '${error['message'] ?? 'JSON-RPC error'}', + data: error['data'], + ); + } + + final int? code; + final String message; + final Object? data; + + @override + String toString() => 'JsonRpcException($code): $message'; +} diff --git a/lib/core/extensions/sandbox/sandbox_credentials_injector.dart b/lib/core/extensions/sandbox/sandbox_credentials_injector.dart new file mode 100644 index 00000000..799b1750 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_credentials_injector.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +/// Mutable UTF-8 buffer that can be zeroed after use (Block E §5). +/// +/// Dart [String] values are immutable and cannot be wiped; keep secrets in +/// [SensitiveUtf8Buffer] while assembling RPC payloads, then [clear]. +class SensitiveUtf8Buffer { + SensitiveUtf8Buffer(String value) : _bytes = Uint8List.fromList(utf8.encode(value)); + + Uint8List? _bytes; + + bool get isCleared => _bytes == null; + + String? get asString { + final bytes = _bytes; + if (bytes == null) return null; + return utf8.decode(bytes); + } + + /// Overwrites the buffer with zeros and drops the reference. + void clear() { + final bytes = _bytes; + if (bytes == null) return; + bytes.fillRange(0, bytes.length, 0); + _bytes = null; + } +} + +/// Loads connection secrets from the OS store and injects them into a sandboxed +/// plugin process exclusively via Stdio JSON-RPC (never argv / env). +class SandboxCredentialsInjector { + SandboxCredentialsInjector({ + this.requestTimeout = const Duration(seconds: 10), + Future<({String? password, String? connectionString})> Function(int connectionId)? + secretsReader, + JsonRpcStdioClient Function(SandboxProcessHandle handle)? clientFactory, + }) : _secretsReader = secretsReader ?? ConnectionSecretsStore.readForConnection, + _clientFactory = clientFactory; + + final Duration requestTimeout; + final Future<({String? password, String? connectionString})> Function( + int connectionId, + ) _secretsReader; + final JsonRpcStdioClient Function(SandboxProcessHandle handle)? _clientFactory; + + /// Reads secrets for [connectionId] and sends `system.injectCredentials`. + /// + /// Returns the RPC result map (or null). Sensitive buffers are cleared in a + /// `finally` block regardless of success or failure. + Future injectCredentials({ + required SandboxProcessHandle handle, + required int connectionId, + Map extraParams = const {}, + }) async { + final secrets = await _secretsReader(connectionId); + final passwordBuf = + secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null; + final connectionStringBuf = secrets.connectionString != null + ? SensitiveUtf8Buffer(secrets.connectionString!) + : null; + + final client = _clientFactory?.call(handle) ?? + JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: requestTimeout, + ); + final ownsClient = _clientFactory == null; + + try { + final params = { + 'connectionId': connectionId, + if (passwordBuf?.asString != null) 'password': passwordBuf!.asString, + if (connectionStringBuf?.asString != null) + 'connectionString': connectionStringBuf!.asString, + ...extraParams, + }; + return await client.sendRequest('system.injectCredentials', params); + } finally { + passwordBuf?.clear(); + connectionStringBuf?.clear(); + if (ownsClient) { + await client.close(); + } + } + } + + /// Reads secrets and sends `db.connect` with host/port plus credentials. + Future connect({ + required SandboxProcessHandle handle, + required int connectionId, + required String host, + required int port, + String? database, + String? username, + bool ssl = false, + Map extraParams = const {}, + }) async { + final secrets = await _secretsReader(connectionId); + final passwordBuf = + secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null; + final connectionStringBuf = secrets.connectionString != null + ? SensitiveUtf8Buffer(secrets.connectionString!) + : null; + + final client = _clientFactory?.call(handle) ?? + JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: requestTimeout, + ); + final ownsClient = _clientFactory == null; + + try { + final params = { + 'connectionId': connectionId, + 'host': host, + 'port': port, + if (database != null) 'database': database, + if (username != null) 'username': username, + 'ssl': ssl, + if (passwordBuf?.asString != null) 'password': passwordBuf!.asString, + if (connectionStringBuf?.asString != null) + 'connectionString': connectionStringBuf!.asString, + ...extraParams, + }; + return await client.sendRequest('db.connect', params); + } finally { + passwordBuf?.clear(); + connectionStringBuf?.clear(); + if (ownsClient) { + await client.close(); + } + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart index 9f09050a..92ed3a0e 100644 --- a/lib/core/extensions/sandbox/sandbox_process_runner.dart +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; /// Live handle for a sandboxed OS process (Block E Level 2). class SandboxProcessHandle { @@ -98,6 +99,9 @@ class SandboxProcessRunner { }) processStarter; /// Spawns [pluginExecutable] inside the OS sandbox for [pluginId]. + /// + /// Credentials must never be passed via [pluginArguments] or [environment]; + /// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead. Future start({ required String pluginId, required String pluginExecutable, @@ -106,6 +110,11 @@ class SandboxProcessRunner { SandboxCapabilities? capabilities, Map? environment, }) async { + SandboxSecretGuard.assertNoSecrets( + arguments: pluginArguments, + environment: environment ?? const {}, + ); + final scratch = await SandboxScratchDirectory.create( pluginId: pluginId, baseDirectory: scratchBaseDirectory, diff --git a/lib/core/extensions/sandbox/sandbox_secret_guard.dart b/lib/core/extensions/sandbox/sandbox_secret_guard.dart new file mode 100644 index 00000000..8bed08b7 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_secret_guard.dart @@ -0,0 +1,76 @@ +/// Guards against leaking secrets via process argv or environment (Block E §5). +class SandboxSecretGuard { + SandboxSecretGuard._(); + + static final _forbiddenEnvKeys = RegExp( + r'(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|connection[_-]?string)', + caseSensitive: false, + ); + + static final _forbiddenArgFlags = RegExp( + r'^--?(password|passwd|secret|token|api-?key|private-?key|connection-string)(=|$)', + caseSensitive: false, + ); + + /// Throws [SandboxSecretLeakException] if [arguments] or [environment] + /// appear to carry credentials. + /// + /// When [knownSecrets] is provided, any exact occurrence of those values in + /// argv or env values is also rejected. + static void assertNoSecrets({ + List arguments = const [], + Map environment = const {}, + Iterable knownSecrets = const [], + }) { + final secrets = knownSecrets + .whereType() + .where((s) => s.isNotEmpty) + .toSet(); + + for (final arg in arguments) { + if (_forbiddenArgFlags.hasMatch(arg)) { + throw SandboxSecretLeakException( + 'Refusing to pass credential flag via process arguments: ' + '${_redactArg(arg)}', + ); + } + for (final secret in secrets) { + if (arg.contains(secret)) { + throw SandboxSecretLeakException( + 'Refusing to pass a known secret value via process arguments.', + ); + } + } + } + + for (final entry in environment.entries) { + if (_forbiddenEnvKeys.hasMatch(entry.key)) { + throw SandboxSecretLeakException( + 'Refusing to pass credential via environment variable "${entry.key}".', + ); + } + for (final secret in secrets) { + if (entry.value.contains(secret)) { + throw SandboxSecretLeakException( + 'Refusing to pass a known secret value via environment ' + '"${entry.key}".', + ); + } + } + } + } + + static String _redactArg(String arg) { + final eq = arg.indexOf('='); + if (eq <= 0) return arg; + return '${arg.substring(0, eq)}=[REDACTED]'; + } +} + +class SandboxSecretLeakException implements Exception { + SandboxSecretLeakException(this.message); + final String message; + + @override + String toString() => 'SandboxSecretLeakException: $message'; +} diff --git a/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart b/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart new file mode 100644 index 00000000..358616cb --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart @@ -0,0 +1,356 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_credentials_injector.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stdoutController = StreamController>.broadcast(), + _stdinController = StreamController>() { + stdin = IOSink(_stdinController.sink); + stdinLines = utf8.decoder + .bind(_stdinController.stream) + .transform(const LineSplitter()) + .asBroadcastStream(); + } + + final StreamController> _stdoutController; + final StreamController> _stdinController; + final _exit = Completer(); + + late final Stream stdinLines; + + @override + int get pid => 4242; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => const Stream.empty(); + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + if (!_exit.isCompleted) _exit.complete(0); + return true; + } + + void reply(Map message) { + _stdoutController.add(utf8.encode('${jsonEncode(message)}\n')); + } +} + +Future _makeHandle( + _FakeProcess process, + Directory tempBase, +) async { + final scratch = await SandboxScratchDirectory.create( + pluginId: 'test.driver', + baseDirectory: tempBase, + token: '1', + ); + return SandboxProcessHandle( + pluginId: 'test.driver', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); +} + +void main() { + group('SandboxSecretGuard', () { + test('allows non-secret argv and env', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--rpc', '--verbose'], + environment: const {'QUERYA_SANDBOX_SCRATCH': '/tmp/x'}, + ), + returnsNormally, + ); + }); + + test('rejects password flags and env keys', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--password=s3cret'], + ), + throwsA(isA()), + ); + expect( + () => SandboxSecretGuard.assertNoSecrets( + environment: const {'DB_PASSWORD': 'x'}, + ), + throwsA(isA()), + ); + }); + + test('rejects known secret substrings in argv', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--dsn=postgres://u:hunter2@h/db'], + knownSecrets: const ['hunter2'], + ), + throwsA(isA()), + ); + }); + }); + + group('SensitiveUtf8Buffer', () { + test('clear zeroes bytes and drops reference', () { + final buf = SensitiveUtf8Buffer('hunter2'); + expect(buf.asString, 'hunter2'); + buf.clear(); + expect(buf.isCleared, isTrue); + expect(buf.asString, isNull); + + final wiped = Uint8List.fromList(utf8.encode('hunter2')); + wiped.fillRange(0, wiped.length, 0); + expect(wiped.every((b) => b == 0), isTrue); + }); + }); + + group('JsonRpcStdioClient', () { + test('sends request and completes with result', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + ); + + final sub = utf8.decoder + .bind(stdin.stream) + .transform(const LineSplitter()) + .listen((line) { + final req = jsonDecode(line) as Map; + stdout.add(utf8.encode('${jsonEncode({ + 'jsonrpc': '2.0', + 'id': req['id'], + 'result': {'ok': true}, + })}\n')); + }); + + final result = await client.sendRequest('system.ping'); + expect(result, {'ok': true}); + await client.close(); + await sub.cancel(); + await stdout.close(); + }); + + test('maps JSON-RPC errors', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + ); + + final sub = utf8.decoder + .bind(stdin.stream) + .transform(const LineSplitter()) + .listen((line) { + final req = jsonDecode(line) as Map; + stdout.add(utf8.encode('${jsonEncode({ + 'jsonrpc': '2.0', + 'id': req['id'], + 'error': {'code': -32000, 'message': 'auth failed'}, + })}\n')); + }); + + await expectLater( + client.sendRequest('db.connect', {'host': 'x'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auth failed', + ), + ), + ); + await client.close(); + await sub.cancel(); + await stdout.close(); + }); + }); + + group('SandboxCredentialsInjector', () { + late Directory tempBase; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_cred_test_'); + }); + + tearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + test('injectCredentials sends system.injectCredentials over stdio', () async { + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final requestCompleter = Completer>(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (!requestCompleter.isCompleted) { + requestCompleter.complete(req); + } + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'injected': true}, + }); + }); + + final injector = SandboxCredentialsInjector( + secretsReader: (id) async { + expect(id, 42); + return (password: 's3cret', connectionString: null); + }, + ); + + final result = await injector.injectCredentials( + handle: handle, + connectionId: 42, + ); + + final req = await requestCompleter.future; + expect(req['method'], 'system.injectCredentials'); + expect(req['params'], containsPair('password', 's3cret')); + expect(req['params'], containsPair('connectionId', 42)); + expect(result, {'injected': true}); + + await sub.cancel(); + await handle.dispose(); + }); + + test('connect sends db.connect with host and password', () async { + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final requestCompleter = Completer>(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (!requestCompleter.isCompleted) { + requestCompleter.complete(req); + } + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'connected': true}, + }); + }); + + final injector = SandboxCredentialsInjector( + secretsReader: (_) async => + (password: 'pw', connectionString: 'postgres://x'), + ); + + final result = await injector.connect( + handle: handle, + connectionId: 7, + host: 'db.example', + port: 5432, + username: 'app', + ssl: true, + ); + + final req = await requestCompleter.future; + expect(req['method'], 'db.connect'); + final params = req['params'] as Map; + expect(params['host'], 'db.example'); + expect(params['port'], 5432); + expect(params['password'], 'pw'); + expect(params['ssl'], isTrue); + expect(result, {'connected': true}); + + await sub.cancel(); + await handle.dispose(); + }); + + test('clears sensitive buffers even when RPC fails', () async { + final cleared = []; + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'error': {'code': 1, 'message': 'nope'}, + }); + }); + + // Verify SensitiveUtf8Buffer.clear semantics used by injector. + final buf = SensitiveUtf8Buffer('temp-secret'); + expect(buf.isCleared, isFalse); + buf.clear(); + cleared.add(buf.isCleared); + + final injector = SandboxCredentialsInjector( + secretsReader: (_) async => (password: 'temp-secret', connectionString: null), + ); + + await expectLater( + injector.injectCredentials(handle: handle, connectionId: 1), + throwsA(isA()), + ); + expect(cleared.single, isTrue); + + await sub.cancel(); + await handle.dispose(); + }); + }); + + group('SandboxProcessRunner secret guard integration', () { + test('start rejects password in arguments before spawn', () async { + var started = false; + final runner = SandboxProcessRunner( + platformOverride: 'windows', + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + started = true; + throw StateError('should not spawn'); + }, + ); + + await expectLater( + () => runner.start( + pluginId: 'bad', + pluginExecutable: 'driver', + pluginArguments: const ['--password=leak'], + ), + throwsA(isA()), + ); + expect(started, isFalse); + }); + }); +}