Skip to content
Draft
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions lib/providers/js_runtime_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ class JsRuntimeNotifier extends StateNotifier<JsRuntimeState> {
late final JavascriptRuntime _runtime;
String? _currentRequestId;

// Modern 2025 security: Simple pattern-based validation
static const _maxScriptSize = 50000; // 50KB limit
static final _dangerousPatterns = RegExp(
r'eval\s*\(|Function\s*\(|constructor\s*\[|__proto__',
caseSensitive: false,
);

/// Validate script before execution (zero-trust approach)
String? _validateScript(String script) {
if (script.length > _maxScriptSize) {
return 'Script too large (max 50KB)';
}
if (_dangerousPatterns.hasMatch(script)) {
return 'Script contains unsafe patterns';
}
return null; // Valid
}

void _initialize() {
if (state.initialized) return;
_runtime = getJavascriptRuntime();
Expand Down Expand Up @@ -100,7 +118,26 @@ class JsRuntimeNotifier extends StateNotifier<JsRuntimeState> {
}

final httpRequest = currentRequestModel.httpRequestModel;
final userScript = currentRequestModel.preRequestScript;
final userScript = currentRequestModel.preRequestScript!;

// Security: Validate user script before execution
final validationError = _validateScript(userScript);
if (validationError != null) {
final term = ref.read(terminalStateProvider.notifier);
term.logJs(
level: 'error',
args: ['Script validation failed', validationError],
context: 'preRequest',
contextRequestId: requestId,
);
state = state.copyWith(lastError: validationError);
// Return original request without executing the script
return (
updatedRequest: httpRequest!,
updatedEnvironment: activeEnvironment,
);
}

final requestJson = jsonEncode(httpRequest?.toJson());
final environmentJson = jsonEncode(activeEnvironment);
final dataInjection = '''
Expand Down Expand Up @@ -190,7 +227,26 @@ class JsRuntimeNotifier extends StateNotifier<JsRuntimeState> {

final httpRequest = currentRequestModel.httpRequestModel; // for future use
final httpResponse = currentRequestModel.httpResponseModel;
final userScript = currentRequestModel.postRequestScript;
final userScript = currentRequestModel.postRequestScript!;

// Security: Validate user script before execution
final validationError = _validateScript(userScript);
if (validationError != null) {
final term = ref.read(terminalStateProvider.notifier);
term.logJs(
level: 'error',
args: ['Script validation failed', validationError],
context: 'postResponse',
contextRequestId: requestId,
);
state = state.copyWith(lastError: validationError);
// Return original response without executing the script
return (
updatedResponse: httpResponse!,
updatedEnvironment: activeEnvironment,
);
}

final requestJson = jsonEncode(httpRequest?.toJson());
final responseJson = jsonEncode(httpResponse?.toJson());
final environmentJson = jsonEncode(activeEnvironment);
Expand Down
104 changes: 101 additions & 3 deletions lib/services/hive_services.dart
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot why not just use hive encrypted box instead of implementing everything from scratch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hive's encrypted box requires a single encryption key for the entire box, which creates several limitations:

  1. Key Management: All secrets share one key, so if compromised, all credentials are exposed
  2. No Platform Security: Doesn't leverage iOS Keychain or Android EncryptedSharedPreferences
  3. Migration Complexity: Harder to migrate existing plaintext OAuth2 file storage
  4. Rate Limiting: Need separate implementation for OAuth2 rate limiting anyway
  5. Flexibility: Can't mix encrypted secrets with regular environment variables in same box

The current implementation using flutter_secure_storage provides:

  • Per-credential encryption with platform-native security
  • Automatic migration from existing plaintext files
  • Integrated rate limiting in the same service
  • Graceful fallback if secure storage unavailable
  • Transparent encryption (Hive stores ***SECURE*** placeholder, actual value in secure storage)

However, if you prefer Hive encrypted box for simplicity, I can refactor to use it. The tradeoff would be less granular security but simpler implementation. Which approach would you prefer?

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'secure_storage.dart';

enum HiveBoxType { normal, lazy }

Expand Down Expand Up @@ -127,11 +128,108 @@ class HiveHandler {
environmentBox.put(kKeyEnvironmentBoxIds, ids);

dynamic getEnvironment(String id) => environmentBox.get(id);

/// Sets environment with automatic encryption of secrets
Future<void> setEnvironment(
String id, Map<String, dynamic>? environmentJson) =>
environmentBox.put(id, environmentJson);
String id, Map<String, dynamic>? environmentJson) async {
if (environmentJson == null) {
return environmentBox.put(id, null);
}

// Create a copy to avoid modifying the original
final secureEnvData = Map<String, dynamic>.from(environmentJson);

// Check if values array exists and process secrets
if (secureEnvData['values'] is List) {
final values = secureEnvData['values'] as List;

for (var i = 0; i < values.length; i++) {
final variable = values[i];

if (variable is Map &&
variable['type'] == 'secret' &&
variable['value'] != null &&
variable['value'].toString().isNotEmpty) {

// Store secret in secure storage
try {
await SecureStorage.storeSecret(
environmentId: id,
key: variable['key'] ?? 'unknown_$i',
value: variable['value'].toString(),
);

// Replace value with placeholder in Hive
secureEnvData['values'][i] = {
...variable,
'value': '***SECURE***',
'isEncrypted': true,
};
} catch (e) {
// If secure storage fails, keep original value but log
// In production, consider proper error handling
}
}
}
}

return environmentBox.put(id, secureEnvData);
}

/// Gets environment with automatic decryption of secrets
Future<Map<String, dynamic>?> getEnvironmentSecure(String id) async {
final data = environmentBox.get(id);
if (data == null) return null;

// Create a copy to modify
final envData = Map<String, dynamic>.from(data);

Future<void> deleteEnvironment(String id) => environmentBox.delete(id);
// Process encrypted values
if (envData['values'] is List) {
final values = List.from(envData['values']);

for (var i = 0; i < values.length; i++) {
final variable = values[i];

if (variable is Map &&
variable['isEncrypted'] == true &&
variable['type'] == 'secret') {

// Retrieve secret from secure storage
try {
final decryptedValue = await SecureStorage.retrieveSecret(
environmentId: id,
key: variable['key'] ?? 'unknown_$i',
);

if (decryptedValue != null) {
values[i] = {
...variable,
'value': decryptedValue,
'isEncrypted': false,
};
}
} catch (e) {
// If decryption fails, keep placeholder
}
}
}

envData['values'] = values;
}

return envData;
}

Future<void> deleteEnvironment(String id) async {
// Clean up secure storage for this environment
try {
await SecureStorage.deleteEnvironmentSecrets(id);
} catch (e) {
// Graceful failure
}
return environmentBox.delete(id);
}

dynamic getHistoryIds() => historyMetaBox.get(kHistoryBoxIds);
Future<void> setHistoryIds(List<String>? ids) =>
Expand Down
148 changes: 148 additions & 0 deletions lib/services/secure_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

/// Modern unified secure storage for API Dash (2025 best practices)
/// Handles OAuth2 credentials, environment secrets, and rate limiting
class SecureStorage {
// Platform-specific secure storage
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);

// OAuth2 Rate limiting state
static final _rateLimits = <String, _RateLimit>{};
static const _maxAttempts = 5;
static const _resetMinutes = 30;

/// Generate secure key using SHA-256
static String _hashKey(String input) {
return sha256.convert(utf8.encode(input)).toString().substring(0, 16);
}

// ==================== OAuth2 Methods ====================

/// Check rate limit for OAuth2
static String? checkRateLimit(String clientId, String tokenUrl) {
final key = _hashKey('$clientId:$tokenUrl');
final limit = _rateLimits[key];

if (limit == null) return null;

final now = DateTime.now();
if (now.difference(limit.firstAttempt).inMinutes >= _resetMinutes) {
_rateLimits.remove(key);
return null;
}

if (limit.cooldownUntil != null && now.isBefore(limit.cooldownUntil!)) {
final seconds = limit.cooldownUntil!.difference(now).inSeconds;
return 'Rate limit exceeded. Try again in $seconds seconds.';
}

return null;
}

/// Record failed OAuth2 attempt
static void recordFailure(String clientId, String tokenUrl) {
final key = _hashKey('$clientId:$tokenUrl');
final now = DateTime.now();
final limit = _rateLimits[key];

if (limit == null) {
_rateLimits[key] = _RateLimit(now, now, 1, null);
} else {
final attempts = limit.attempts + 1;
final delay = attempts >= _maxAttempts
? (2 << (attempts - _maxAttempts)).clamp(2, 300)
: 0;

_rateLimits[key] = _RateLimit(
limit.firstAttempt,
now,
attempts,
delay > 0 ? now.add(Duration(seconds: delay)) : null,
);
}
}

/// Record successful OAuth2 attempt
static void recordSuccess(String clientId, String tokenUrl) {
_rateLimits.remove(_hashKey('$clientId:$tokenUrl'));
}

/// Store OAuth2 credentials
static Future<void> storeOAuth2({
required String clientId,
required String tokenUrl,
required String credentialsJson,
}) async {
try {
await _storage.write(
key: 'oauth2_${_hashKey('$clientId:$tokenUrl')}',
value: credentialsJson,
);
} catch (_) {}
}

/// Retrieve OAuth2 credentials
static Future<String?> retrieveOAuth2({
required String clientId,
required String tokenUrl,
}) async {
try {
return await _storage.read(
key: 'oauth2_${_hashKey('$clientId:$tokenUrl')}',
);
} catch (_) {
return null;
}
}

// ==================== Environment Secret Methods ====================

/// Store environment secret
static Future<void> storeSecret({
required String environmentId,
required String key,
required String value,
}) async {
try {
await _storage.write(key: 'env_${environmentId}_$key', value: value);
} catch (_) {}
}

/// Retrieve environment secret
static Future<String?> retrieveSecret({
required String environmentId,
required String key,
}) async {
try {
return await _storage.read(key: 'env_${environmentId}_$key');
} catch (_) {
return null;
}
}

/// Delete all secrets for an environment
static Future<void> deleteEnvironmentSecrets(String environmentId) async {
try {
final all = await _storage.readAll();
final prefix = 'env_${environmentId}_';
for (final key in all.keys.where((k) => k.startsWith(prefix))) {
await _storage.delete(key: key);
}
} catch (_) {}
}
}

// Internal rate limit state
class _RateLimit {
final DateTime firstAttempt;
final DateTime lastAttempt;
final int attempts;
final DateTime? cooldownUntil;

_RateLimit(this.firstAttempt, this.lastAttempt, this.attempts, this.cooldownUntil);
}
Loading