diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..79618ac --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,3 @@ +# Sentinel Security Journal + +## 2026-09-13 - AI Endpoint Transport Security Validation **Vulnerability:** Remote AI service endpoints permitted unencrypted HTTP, risking plaintext transmission of user credentials and sensitive AI prompt data over untrusted networks. **Learning:** User-configurable endpoints often mix remote API services with local development servers (e.g., Ollama), requiring explicit differentiation between loopback hosts and remote endpoints. **Prevention:** Enforce HTTPS scheme validation for remote endpoints while allowing HTTP strictly for local loopback hosts (`localhost`, `127.0.0.1`, `::1`). diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a005a2f..9208d22 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:janus/models/app_settings.dart'; +import 'package:janus/services/ai_endpoint_validator.dart'; import 'package:janus/services/settings_service.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -192,6 +193,17 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { } // AI settings + Future setEndPoint(String endPoint) async { + final trimmed = endPoint.trim(); + if (trimmed.isNotEmpty && !AiEndpointValidator.isValidEndpoint(trimmed)) { + return false; + } + await _persist( + (state.value ?? const AppSettings()).copyWith(endPoint: trimmed), + ); + return true; + } + Future setUseAiDailySummary(bool enabled) async { await _persist( (state.value ?? const AppSettings()).copyWith(aiDailySummary: enabled), diff --git a/lib/services/ai_endpoint_validator.dart b/lib/services/ai_endpoint_validator.dart new file mode 100644 index 0000000..2a9fe89 --- /dev/null +++ b/lib/services/ai_endpoint_validator.dart @@ -0,0 +1,26 @@ +/// Utility for validating AI service API endpoints for secure transport. +class AiEndpointValidator { + AiEndpointValidator._(); + + /// Validates whether [url] is a secure AI endpoint URL. + /// + /// Enforces HTTPS for remote connections to prevent plaintext credential + /// interception via MITM attacks, while permitting HTTP for local loopback hosts + /// (`localhost`, `127.0.0.1`, `::1`) used during local LLM development. + static bool isValidEndpoint(String url) { + if (url.trim().isEmpty) return false; + final uri = Uri.tryParse(url.trim()); + if (uri == null || !uri.hasScheme) return false; + + final scheme = uri.scheme.toLowerCase(); + if (scheme == 'https') return true; + if (scheme == 'http') { + final host = uri.host.toLowerCase(); + return host == 'localhost' || + host == '127.0.0.1' || + host == '::1' || + host == '[::1]'; + } + return false; + } +} diff --git a/test/unit/services/ai_endpoint_validator_test.dart b/test/unit/services/ai_endpoint_validator_test.dart new file mode 100644 index 0000000..8560a2b --- /dev/null +++ b/test/unit/services/ai_endpoint_validator_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:janus/services/ai_endpoint_validator.dart'; + +void main() { + group('AiEndpointValidator', () { + test('allows valid HTTPS remote URLs', () { + expect( + AiEndpointValidator.isValidEndpoint('https://api.openai.com/v1'), + isTrue, + ); + expect( + AiEndpointValidator.isValidEndpoint('https://api.deepseek.com'), + isTrue, + ); + }); + + test('rejects unencrypted HTTP remote URLs', () { + expect( + AiEndpointValidator.isValidEndpoint('http://api.openai.com/v1'), + isFalse, + ); + expect( + AiEndpointValidator.isValidEndpoint('http://example.com/api'), + isFalse, + ); + }); + + test('allows HTTP URLs for local development loopback hosts', () { + expect( + AiEndpointValidator.isValidEndpoint('http://localhost:11434'), + isTrue, + ); + expect( + AiEndpointValidator.isValidEndpoint('http://127.0.0.1:8080/v1'), + isTrue, + ); + expect(AiEndpointValidator.isValidEndpoint('http://[::1]:11434'), isTrue); + }); + + test('rejects empty, invalid, or non-http/https URIs', () { + expect(AiEndpointValidator.isValidEndpoint(''), isFalse); + expect(AiEndpointValidator.isValidEndpoint(' '), isFalse); + expect(AiEndpointValidator.isValidEndpoint('ftp://server.com'), isFalse); + expect(AiEndpointValidator.isValidEndpoint('not_a_url'), isFalse); + }); + }); +} diff --git a/test/unit/settings/settings_provider_test.dart b/test/unit/settings/settings_provider_test.dart index c799fe9..247db5d 100644 --- a/test/unit/settings/settings_provider_test.dart +++ b/test/unit/settings/settings_provider_test.dart @@ -414,6 +414,33 @@ void main() { }); // AI + test('setEndPoint saves valid HTTPS and loopback endpoints', () async { + final container = ProviderContainer(); + addTearDown(() => container.dispose()); + await waitForInit(container); + + final success = await notifierOf( + container, + ).setEndPoint('https://api.openai.com/v1'); + expect(success, isTrue); + expect( + container.read(appSettingsProvider).value!.endPoint, + 'https://api.openai.com/v1', + ); + }); + + test('setEndPoint rejects unencrypted remote HTTP endpoints', () async { + final container = ProviderContainer(); + addTearDown(() => container.dispose()); + await waitForInit(container); + + final success = await notifierOf( + container, + ).setEndPoint('http://insecure.remote.com/api'); + expect(success, isFalse); + expect(container.read(appSettingsProvider).value!.endPoint, ''); + }); + test('setUseAiDailySummary 更新 AI 日报开关', () async { final container = ProviderContainer(); addTearDown(() => container.dispose());