diff --git a/.env.sample b/.env.sample index 0a6f7fe..b0aac49 100644 --- a/.env.sample +++ b/.env.sample @@ -1,10 +1,13 @@ -SERVER_SCHEME = https -SERVER_HOST = example.com -SERVER_PORT = 443 -SERVER_USERNAME = user -SERVER_PASSWORD = passw0rd -LOCAL_BIND_IP = 127.0.0.1 -LOCAL_PORT = 8080 +HTTP_PROXY = http://username:pa$$w0rd@example.com:1337/ +REMOTE_URI = https://username:pa$$w0rd@example.com:443/ +REMOTE_SCHEME = https +REMOTE_USERNAME = user +REMOTE_PASSWORD = passw0rd +REMOTE_HOST = example.com +REMOTE_PORT = 443 +LOCAL_URI = http://user:passw0rd@127.0.0.1:8080/ +LOCAL_SCHEME = http LOCAL_USERNAME = user LOCAL_PASSWORD = passw0rd -HTTP_PROXY = http://username:pa$$w0rd@example.com:1337/ +LOCAL_HOST = 127.0.0.1 +LOCAL_PORT = 8080 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5632db3..4b38a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +# 1.2.0 + +- Add `Access-Control-Expose-Headers` header if none is provided by server. +- Rename environment variables: + `SERVER_SCHEME` -> `REMOTE_SCHEME` + `SERVER_HOST` -> `REMOTE_HOST` + `SERVER_USERNAME` -> `REMOTE_USERNAME` + `SERVER_PASSWORD` -> `REMOTE_PASSWORD` + `SERVER_POST` -> `REMOTE_POST` + `LOCAL_BIND_IP` -> `LOCAL_HOST` +- Now you can use just `LOCAL_URI` and `REMOTE_URI` same as `HTTP_PROXY`, + this simplifies usage from console. + # 1.1.2 - Add support for internal redirects. diff --git a/ReadMe.md b/ReadMe.md index aa08957..3b49b58 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -5,34 +5,60 @@ It will help you to keep private credentials out of build artifacts. ## Features -* Target server authentication +* Remote server authentication * Local server authentication -* Spoofing referrer +* Referrer spoofing (pretend that request came from target origin) * CORS bypass * HTTP proxy support +* Adds `Access-Control-Expose-Headers` to allow browser inspect headers. ## Usage +### From console + +You only need to provide `REMOTE_URI` and optionally `LOCAL_URI` (defaults to +`http://127.0.0.1:8080`) + +```shell +REMOTE_URI=http://localhost:8080/ +LOCAL_URI=http://127.0.0.1:8081/ +``` + +### Via config (`.env` file) Use environmental variables or `.env` file in working directory or pass it's location as first argument: + ```dotenv -# Remote HTTP(S) server +## Remote HTTP(S) server + +# Remote server URI (preferred) +REMOTE_URI = https://username:pa$$w0rd@example.com:443/ + +# Or exploded URI SERVER_SCHEME = https SERVER_HOST = example.com SERVER_PORT = 443 # Remote server HTTP Basic auth (optional) -SERVER_USERNAME = user -SERVER_PASSWORD = passw0rd -# Local HTTP server -LOCAL_BIND_IP = 127.0.0.1 +REMOTE_USERNAME = user +REMOTE_PASSWORD = passw0rd + +## Local HTTP server + +# Local server URI (preferred) +LOCAL_URI = http://user:passw0rd@127.0.0.1:8080/ +# Or exploded URI +LOCAL_HOST = 127.0.0.1 LOCAL_PORT = 8080 # Local server HTTP Basic auth (optional) LOCAL_USERNAME = user LOCAL_PASSWORD = passw0rd + # HTTP proxy (optional) HTTP_PROXY = http://username:pa$$w0rd@example.com:1337/ ``` +## Notes + Keep in mind that if you have local server authentication, you won't be able to send authentication details to a remote server through a mirror. diff --git a/bin/main.dart b/bin/main.dart index 4325690..55efa2d 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,13 +1,14 @@ -import 'dart:convert'; import 'dart:io'; -import 'package:collection/collection.dart'; -import 'package:dotenv/dotenv.dart' as dotenv; +import 'package:dev_mirror/config.dart'; +import 'package:dev_mirror/secure_compare.dart'; +import 'package:dev_mirror/uri_basic_auth.dart'; +import 'package:dev_mirror/uri_credentials.dart'; +import 'package:dev_mirror/uri_has_origin.dart'; import 'package:uri/uri.dart'; /// Invalid string URI. -const _invalidUri = '::Not valid URI::'; const headersNotToForwardToRemote = [ HttpHeaders.hostHeader, ]; @@ -22,103 +23,56 @@ const headersToSpoofBeforeForwardFromRemote = [ ]; -extension UriHasOrigin on Uri { - bool get hasOrigin => (scheme == 'http' || scheme == 'https') && host != ''; -} - /// List of additional root CA final List trustedRoots = [ [72, 80, 78, 151, 76, 13, 172, 91, 92, 212, 118, 200, 32, 34, 116, 178, 76, 140, 113, 114], // DST Root CA X3 ].map(String.fromCharCodes).toList(); -bool secureCompare(String a, String b) { - if(a.codeUnits.length != b.codeUnits.length) - return false; - - var r = 0; - for(var i = 0; i < a.codeUnits.length; i++) { - r |= a.codeUnitAt(i) ^ b.codeUnitAt(i); - } - return r == 0; -} - -/// Returns environment variable or `.env` variable -String? getEnv(String variable) => - Platform.environment[variable] ?? dotenv.env[variable]; - /// Adds CORS headers to [response] void addCORSHeaders(HttpRequest request, HttpResponse response) { final refererUri = Uri.tryParse( - request.headers[HttpHeaders.refererHeader]?.singleOrNull ?? _invalidUri, + request.headers[HttpHeaders.refererHeader]?.singleOrNull ?? '::INVALID::', ); response.headers - ..add( + ..set( HttpHeaders.accessControlAllowOriginHeader, (refererUri != null && refererUri.hasOrigin) ? refererUri.origin : '*', ) - ..add( + ..set( HttpHeaders.accessControlAllowMethodsHeader, request.headers[HttpHeaders.accessControlRequestMethodHeader]?.join(',') ?? '*', ) - ..add( + ..set( HttpHeaders.accessControlAllowHeadersHeader, request.headers[HttpHeaders.accessControlRequestHeadersHeader]?.join(',') ?? 'authorization,*', ) - ..add( + ..set( HttpHeaders.accessControlAllowCredentialsHeader, 'true', + ) + ..set( + HttpHeaders.accessControlExposeHeadersHeader, + request.headers[HttpHeaders.accessControlExposeHeadersHeader]?.join(',') + ?? 'authorization,*', ); } void main(List arguments) async { final dotEnvFile = arguments.firstOrNull ?? '.env'; - if (File.fromUri(Uri.file(dotEnvFile)).existsSync()) - dotenv.load(dotEnvFile); - - // Local server bind settings - final localBindIp = InternetAddress.tryParse(getEnv('LOCAL_BIND_IP') ?? '') ?? InternetAddress.loopbackIPv4; - final localPort = int.tryParse(getEnv('LOCAL_PORT') ?? '') ?? 8080; - - // Local auth - final localUsername = getEnv('LOCAL_USERNAME'); - final localPassword = getEnv('LOCAL_PASSWORD'); - final localBasicAuth = (localUsername == null || localPassword == null) ? null - : 'Basic ${base64Encode(utf8.encode('$localUsername:$localPassword'))}'; - final localBaseUrl = 'http://${localBindIp.host}:$localPort'; - - // Remote server - final remoteScheme = getEnv('SERVER_SCHEME') ?? 'https'; - final remoteHost = getEnv('SERVER_HOST') ?? 'example.com'; - final remotePort = int.tryParse(getEnv('SERVER_PORT') ?? (remoteScheme == 'https' ? '443' : '')) ?? 80; + final config = Config.load(dotEnvFile); - // Remote server auth - final remoteUsername = getEnv('SERVER_USERNAME'); - final remotePassword = getEnv('SERVER_PASSWORD'); - final remoteBasicAuth = (remoteUsername == null || remotePassword == null) ? null - : 'Basic ${base64Encode(utf8.encode('$remoteUsername:$remotePassword'))}'; - final serverBaseUrl = '$remoteScheme://$remoteHost${![ 'http', 'https', ].contains(remoteScheme) ? remotePort : ''}'; - - // HTTP proxy - final httpProxy = Uri.tryParse(getEnv('HTTP_PROXY') ?? _invalidUri); - final httpProxyCredentialsMatch = httpProxy == null ? null - : RegExp(r'^(?.+?):(?.+?)$').firstMatch(httpProxy.userInfo); - final httpProxyUsername = httpProxyCredentialsMatch?.namedGroup('username'); - final httpProxyPassword = httpProxyCredentialsMatch?.namedGroup('password'); - final httpProxyCredentials = (httpProxyUsername == null || httpProxyPassword == null) ? null - : HttpClientBasicCredentials(httpProxyUsername, httpProxyPassword); - - stdout.write('Starting mirror server $localBaseUrl -> $serverBaseUrl'); - if (localBasicAuth != null) + stdout.write('Starting mirror server ${config.local} -> ${config.remote}'); + if (config.local.basicAuth != null) stdout.write(' [Local auth]'); - if (remoteBasicAuth != null) + if (config.remote.basicAuth != null) stdout.write(' [Remote auth auto-fill]'); - if (httpProxy != null) { + if (config.proxy != null) { stdout.write(' [Through HTTP proxy]'); - if (httpProxy.scheme != 'http') { + if (config.proxy!.scheme != 'http') { stdout.writeln(' [Error]'); stderr.writeln('Proxy URI must be valid.'); return; @@ -127,7 +81,7 @@ void main(List arguments) async { late final HttpServer server; try { - server = await HttpServer.bind(localBindIp, localPort); + server = await HttpServer.bind(config.local.host, config.local.port); } catch(error) { stdout.writeln(' [Error]'); stderr @@ -143,16 +97,17 @@ void main(List arguments) async { trustedRoots.contains(String.fromCharCodes(cert.sha1)); // Apply HTTP proxy - if (httpProxy != null) { - if (httpProxyCredentials != null) { + if (config.proxy case final Uri proxy) { + final credentials = proxy.httpClientCredentials; + if (credentials != null) { client.addProxyCredentials( - httpProxy.host, - httpProxy.port, + proxy.host, + proxy.port, 'Basic', - httpProxyCredentials, + credentials, ); } - client.findProxy = (uri) => 'PROXY ${httpProxy.host}:${httpProxy.port}'; + client.findProxy = (uri) => 'PROXY ${proxy.host}:${proxy.port}'; } var requestId = 0; @@ -162,13 +117,12 @@ void main(List arguments) async { final response = request.response; - addCORSHeaders(request, response); - // Handle preflight if ( request.method.toUpperCase() == 'OPTIONS' && request.headers[HttpHeaders.accessControlRequestMethodHeader] != null ) { + addCORSHeaders(request, response); stdout.writeln('[$requestId] Preflight handled.'); response ..contentLength = 0 @@ -177,6 +131,7 @@ void main(List arguments) async { return; } + final localBasicAuth = config.local.basicAuth; if (localBasicAuth != null) { final _userAuth = request.headers[HttpHeaders.authorizationHeader]?.singleOrNull; if (_userAuth == null || !secureCompare(_userAuth, localBasicAuth)) { @@ -192,9 +147,9 @@ void main(List arguments) async { } final remoteUri = (UriBuilder.fromUri(request.uri) - ..scheme = remoteScheme - ..host = remoteHost - ..port = remotePort + ..scheme = config.remote.scheme + ..host = config.remote.host + ..port = config.remote.port ).build(); stdout.writeln('[$requestId] Forwarding: ${request.method} $remoteUri'); @@ -205,6 +160,7 @@ void main(List arguments) async { requestToRemote.followRedirects = false; // Remote server auth + final remoteBasicAuth = config.remote.basicAuth; if (remoteBasicAuth != null) requestToRemote.headers.add(HttpHeaders.authorizationHeader, remoteBasicAuth); @@ -216,7 +172,7 @@ void main(List arguments) async { requestToRemote.headers.add( headerName, headerValues.map( - (value) => value.replaceAll(localBaseUrl, serverBaseUrl), + (value) => value.replaceAll(config.local.toString(), config.remote.toString()), ), ); else @@ -242,7 +198,7 @@ void main(List arguments) async { response.headers.add( headerName, headerValues.map( - (value) => value.replaceAll(serverBaseUrl, localBaseUrl), + (value) => value.replaceAll(config.remote.toString(), config.local.toString()), ), ); // Add headers as-is @@ -250,6 +206,7 @@ void main(List arguments) async { response.headers.add(headerName, headerValues); }); response.statusCode = remoteResponse.statusCode; + addCORSHeaders(request, response); // Pipe remote response remoteResponse @@ -277,6 +234,7 @@ void main(List arguments) async { ..writeln('[$requestId] Mirror error:') ..writeln(_error); + addCORSHeaders(request, response); response ..statusCode = HttpStatus.internalServerError ..headers.contentType = ContentType.text diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..322a2ca --- /dev/null +++ b/lib/config.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:dotenv/dotenv.dart'; + +class Config { + const Config._({ + required this.local, + required this.remote, + this.proxy, + }); + + factory Config.load(String? path) { + if (path != null && File(path).existsSync()) + dotenv.load([ path, ]); + + final proxy = getUri('HTTP_PROXY'); + final local = getUri('LOCAL') + ?? Uri.http('127.0.0.1:8080'); + + final remote = getUri('REMOTE') + ?? Uri.https('example.com'); + + return Config._( + proxy: proxy, + local: local, + remote: remote, + ); + } + + final Uri? proxy; + final Uri local; + final Uri remote; + + static final dotenv = DotEnv(); + + /// Returns environment variable or `.env` variable + static String? getString(String variable) => + Platform.environment[variable] ?? dotenv[variable]; + + static T? getNum(String variable) => switch(getString(variable)) { + final String value => switch(T) { + double => double.tryParse(value), + int => int.tryParse(value), + _ => null, + } as T?, + _ => null, + }; + + static Uri? getUri(String prefix) => getFullUri('${prefix}_URI') + ?? getExplodedUri(prefix); + + static Uri? getFullUri(String variable) => switch(getString(variable)) { + final String value => Uri.tryParse(value), + _ => null, + }; + + static Uri? getExplodedUri(String prefix) => switch(( + getString('${prefix}_SCHEME'), + getString('${prefix}_HOST'), + )) { + (final scheme?, final host?) => Uri( + scheme: scheme, + userInfo: switch(( + getString('${prefix}_USERNAME'), + getString('${prefix}_PASSWORD'), + )) { + (final username?, final password?) => '$username:$password', + _ => null, + }, + host: host, + port: getNum('${prefix}_PORT') ?? switch(scheme) { + 'https' => 443, + 'http' => 80, + _ => null, + }, + ), + _ => null, + }; +} diff --git a/lib/secure_compare.dart b/lib/secure_compare.dart new file mode 100644 index 0000000..ccad5c2 --- /dev/null +++ b/lib/secure_compare.dart @@ -0,0 +1,10 @@ +bool secureCompare(String a, String b) { + if(a.codeUnits.length != b.codeUnits.length) + return false; + + var r = 0; + for(var i = 0; i < a.codeUnits.length; i++) { + r |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return r == 0; +} diff --git a/lib/uri_basic_auth.dart b/lib/uri_basic_auth.dart new file mode 100644 index 0000000..7d0ab3d --- /dev/null +++ b/lib/uri_basic_auth.dart @@ -0,0 +1,8 @@ +import 'dart:convert'; + + +extension UriBasicAuth on Uri { + String? get basicAuth => userInfo.isNotEmpty + ? 'Basic ${base64Encode(utf8.encode(userInfo))}' + : null; +} diff --git a/lib/uri_credentials.dart b/lib/uri_credentials.dart new file mode 100644 index 0000000..ce64e28 --- /dev/null +++ b/lib/uri_credentials.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + + +extension UriCredentials on Uri { + ({ String username, String password, })? get credentials { + final credentialsMatch = RegExp(r'^(?.+?):(?.+?)$') + .firstMatch(userInfo); + final username = credentialsMatch?.namedGroup('username'); + final password = credentialsMatch?.namedGroup('password'); + if (username == null || password == null) + return null; + return (username: username, password: password); + } + + HttpClientBasicCredentials? get httpClientCredentials => switch(credentials) { + (:final username, :final password) => HttpClientBasicCredentials( + username, + password, + ), + _ => null, + }; +} diff --git a/lib/uri_has_origin.dart b/lib/uri_has_origin.dart new file mode 100644 index 0000000..095b599 --- /dev/null +++ b/lib/uri_has_origin.dart @@ -0,0 +1,3 @@ +extension UriHasOrigin on Uri { + bool get hasOrigin => (scheme == 'http' || scheme == 'https') && host != ''; +} diff --git a/pubspec.lock b/pubspec.lock index 5e688f7..e7a03dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,58 +5,74 @@ packages: dependency: transitive description: name: args - sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" collection: - dependency: "direct main" + dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" dotenv: dependency: "direct main" description: name: dotenv - sha256: dc4c91d8d5e9e361803fc81e8ea7ba0f35be483b656837ea6b9a3c41df308c68 + sha256: e169b516bc7b88801919e1c508772bcb8e3d0d1776a43f74ab692c57e741cd8a url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.1.0" flutter_lints: dependency: transitive description: name: flutter_lints - sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" lints: dependency: transitive description: name: lints - sha256: "5e4a9cd06d447758280a8ac2405101e0e2094d2a1dbdd3756aec3fe7775ba593" + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.1" matcher: dependency: transitive description: name: matcher - sha256: c94db23593b89766cda57aab9ac311e3616cf87c6fa4e9749df032f66f30dcb8 + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.14" + version: "0.12.16" meta: dependency: transitive description: name: meta - sha256: "12307e7f0605ce3da64cf0db90e5fcab0869f3ca03f76be6bb2991ce0a55e82b" + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path: dependency: transitive description: @@ -73,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" stack_trace: dependency: transitive description: @@ -81,6 +105,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" uri: dependency: "direct main" description: @@ -98,4 +154,4 @@ packages: source: hosted version: "1.2.0" sdks: - dart: ">=2.18.0 <4.0.0" + dart: ">=3.1.0-0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1413bc7..22f4590 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,15 +2,14 @@ publish_to: none name: dev_mirror description: Development proxy for accessing private APIs. -version: 1.1.2 +version: 1.2.0 homepage: https://github.com/Zekfad/dev-mirror environment: - sdk: '>=2.17.6 <3.0.0' + sdk: '>=3.1.0-0 <4.0.0' dependencies: - collection: ^1.16.0 - dotenv: ^3.0.0 + dotenv: ^4.1.0 uri: ^1.0.0 dev_dependencies: zekfad_lints: ^1.1.0