JJ_MKVMaker/test/net_security_test.dart

122 lines
7.1 KiB
Dart

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<FileSystemException>()), 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().wifiShareTailscale, isTrue);
expect(AppSettings.fromJson(const {'wifiShareTailscale': false}).wifiShareTailscale, isFalse);
expect(AppSettings.fromJson(const {'wifiShareTailscale': 'x'}).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<SocketException>()));
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<SocketException>()), 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');
});
}