import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/lan_scan.dart'; import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/wifi_share.dart'; import 'package:path/path.dart' as p; /// 새 네트워크 기능 보안 검토 (관리자 지시 10/18) 의 고침: /// 1 원격 이름으로 로컬 경로 벗어나기 · 3 Wi-Fi 공유는 Wi-Fi · 유선 주소에만 · 4 SMB 찾기는 Wi-Fi · 유선만 · 11 mDNS 는 사설망 주소만 void main() { test('1: 원격 목록은 그 폴더 바로 아래의 평범한 이름만 ("..", "\\", NUL, 더 깊은 것 · 다른 폴더는 버림)', () { expect(remoteChildOk('/', '/a.txt'), isTrue); expect(remoteChildOk('/video', '/video/한글 이름.mkv'), isTrue); expect(remoteChildOk('/video/', '/video/x'), isTrue); for (final bad in ['/video/..', '/video/.', '/video/', '/video/..\\..\\x', '/video/a\u0000b', '/video/a/b', '/other/x', 'x']) { expect(remoteChildOk('/video', bad), isFalse, reason: bad); } }); test('1: 원격 경로의 ".." 마디는 받지 않는다 (\\ 로 쓴 것도)', () { expect(DavPath.parse('sftp://nas/a/b').rel, '/a/b'); expect(DavPath.parse('dav://nas/a..b/c').rel, '/a..b/c', reason: '이름 안의 점 두 개는 괜찮다'); for (final bad in ['sftp://nas/a/../b', r'ftp://nas/a\..\..\x', 'dav://nas/..', 'smb://nas/a/..']) { expect(() => DavPath.parse(bad), throwsA(isA()), reason: bad); } }); test('1: 원격 파일을 받는 임시 사본은 늘 임시 폴더 안 (서버 id 가 ".." 여도)', () async { final tmp = Directory.systemTemp.createTempSync('jj_netsec_'); addTearDown(() => tmp.deleteSync(recursive: true)); await expectLater(vLocalCopy('sftp://../x.txt', tmp.path), throwsA(anything)); expect(File(p.join(tmp.parent.path, 'x.txt')).existsSync(), isFalse); }); test('3 · 4: Wi-Fi · 유선 연결만 (모바일 데이터 · 가상 어댑터 · VPN 은 뺀다), Wi-Fi 공유는 Tailscale 주소도', () { for (final n in ['Wi-Fi', '이더넷', 'Ethernet 2', 'wlan0', 'eth0', 'en0', 'swlan0']) { expect(isLanInterfaceName(n), isTrue, reason: n); } for (final n in [ 'rmnet_data0', 'v4-rmnet_data1', 'ccmni1', 'pdp0', 'vEthernet (WSL)', 'vEthernet (Default Switch)', 'VirtualBox Host-Only Network', 'VMware Network Adapter VMnet8', 'docker0', 'br-1a2b', 'tun0', 'ppp0', 'wg0', 'Tailscale', ]) { expect(isLanInterfaceName(n), isFalse, reason: n); } expect(WifiShare.shareInterface('Wi-Fi', '192.168.0.5'), isTrue); expect(WifiShare.shareInterface('Tailscale', '100.69.29.106'), isTrue); expect(WifiShare.shareInterface('tun0', '100.80.1.2'), isTrue, reason: 'Android 의 Tailscale'); expect(WifiShare.shareInterface('rmnet_data0', '100.70.1.2'), isFalse, reason: '통신사 CGNAT'); expect(WifiShare.shareInterface('rmnet_data0', '10.20.30.40'), isFalse); expect(WifiShare.shareInterface('vEthernet (WSL)', '172.24.240.1'), isFalse); expect(WifiShare.shareInterface('VirtualBox Host-Only Network', '192.168.56.1'), isFalse); expect(WifiShare.shareInterface('Wi-Fi', '169.254.10.1'), isFalse, reason: '주소를 못 받은 연결'); // 설정 "Tailscale 에서도 열기" 를 끄면 Tailscale 주소는 열지 않는다 (처음 값은 켬) expect(WifiShare.shareInterface('Tailscale', '100.69.29.106', tailscale: false), isFalse); expect(WifiShare.shareInterface('Wi-Fi', '192.168.0.5', tailscale: false), isTrue); expect(AppSettings().tools.wifiShareTailscale, isTrue); expect(AppSettings.fromJson(const {'wifiShareTailscale': false}).tools.wifiShareTailscale, isFalse); expect(AppSettings.fromJson(const {'wifiShareTailscale': 'x'}).tools.wifiShareTailscale, isTrue, reason: '틀린 값은 처음 값'); }); test('11: mDNS 로 찾은 주소는 사설망만', () { for (final ip in ['192.168.0.15', '10.0.0.2', '172.16.5.5']) { expect(isPrivateLanAddress(ip), isTrue, reason: ip); } for (final ip in ['8.8.8.8', '100.69.29.106', '172.32.0.1', '127.0.0.1', '169.254.1.1', '1.2.3', 'x', '300.1.1.1']) { expect(isPrivateLanAddress(ip), isFalse, reason: ip); } }); test('3: Wi-Fi · 유선 주소가 없으면 공유를 켜지 않는다 (모든 연결 0.0.0.0 에 열지 않음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_netsec_'); addTearDown(() => tmp.deleteSync(recursive: true)); WifiShare.debugAddresses = const []; addTearDown(() => WifiShare.debugAddresses = null); await expectLater(WifiShare.start(tmp.path), throwsA(isA())); expect(WifiShare.instance, isNull); }); test('3: 공유는 고른 주소에만 열린다 (127.0.0.1 로 열면 다른 주소로는 닿지 않음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_netsec_'); addTearDown(() => tmp.deleteSync(recursive: true)); WifiShare.debugAddresses = const ['127.0.0.1']; addTearDown(() => WifiShare.debugAddresses = null); final s = await WifiShare.start(tmp.path, port: 0); addTearDown(s.stop); expect(s.addresses, ['127.0.0.1']); final ok = await Socket.connect('127.0.0.1', s.port); ok.destroy(); // 이 PC 의 다른 주소 (있으면) 로는 연결되지 않는다 for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { for (final a in ni.addresses) { await expectLater(Socket.connect(a, s.port, timeout: const Duration(seconds: 1)), throwsA(isA()), reason: a.address); } } }); test('관리자 판단 (10/19): Android 가 알려 준 망 종류가 있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 빼고 Tailscale 은 설정대로', () { const kinds = { '192.168.0.5': 'wifi', '10.1.2.3': 'ethernet', '10.20.30.40': 'cellular', '10.8.0.2': 'vpn', '100.80.1.2': 'vpn', '100.70.1.2': 'cellular', }; // 이름이 Wi-Fi 같아도 종류가 모바일 · VPN 이면 뺀다 (이름이 낯선 기기 - 예: 제조사마다 다른 이름) expect(isLanAddress('192.168.0.5', 'oddname0', kinds), isTrue); expect(isLanAddress('10.1.2.3', 'eth9', kinds), isTrue); expect(isLanAddress('10.20.30.40', 'wlan0', kinds), isFalse); expect(isLanAddress('10.8.0.2', 'wlan1', kinds), isFalse); // 종류를 모르는 주소 (예: 휴대폰 핫스팟 · Android 가 아님) 는 이름으로 expect(isLanAddress('192.168.43.1', 'wlan0', kinds), isTrue); expect(isLanAddress('192.168.43.1', 'rmnet0', null), isFalse); expect(WifiShare.shareInterface('oddname0', '192.168.0.5', kinds: kinds), isTrue); expect(WifiShare.shareInterface('wlan0', '10.20.30.40', kinds: kinds), isFalse); expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds), isTrue, reason: 'Tailscale (VPN) 은 설정대로'); expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds, tailscale: false), isFalse); expect(WifiShare.shareInterface('oddname1', '100.70.1.2', kinds: kinds), isFalse, reason: '통신사 CGNAT'); }); }