JJ_MKVMaker/test/wifi_share_test.dart

240 lines
11 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/wifi_share.dart';
import 'package:path/path.dart' as p;
import 'support/loopback_net.dart';
/// 89: Wi-Fi 공유 서버 - 코드 없으면 401 · 틀리면 막기 · 폴더 밖 · 링크 403 · 읽기만 (405) · Range · 격리 · 저절로 끄기
void main() {
late Directory tmp, shared;
late WifiShare s;
final client = HttpClient();
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_wifi_');
shared = Directory(p.join(tmp.path, 'shared'))..createSync();
File(p.join(shared.path, 'a.txt')).writeAsStringSync('0123456789');
File(p.join(shared.path, '한글 이름.srt')).writeAsStringSync('자막');
File(p.join(shared.path, 'page.html')).writeAsStringSync('<script>alert(1)</script>');
Directory(p.join(shared.path, 'sub')).createSync();
File(p.join(shared.path, 'sub', 'b.bin')).writeAsBytesSync([1, 2, 3]);
File(p.join(tmp.path, 'secret.txt')).writeAsStringSync('밖의 파일');
s = await WifiShare.start(shared.path, port: 0, random: Random(7), net: LoopbackNet());
});
tearDown(() async {
await s.stop();
for (var i = 0; i < 5 && tmp.existsSync(); i++) {
try {
tmp.deleteSync(recursive: true);
} on FileSystemException {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
});
Future<HttpClientResponse> get(String path, {String? cookie, String method = 'GET', Map<String, String> headers = const {}}) async {
final req = await client.openUrl(method, Uri.parse('http://127.0.0.1:${s.port}$path'));
req.followRedirects = false;
if (cookie != null) req.headers.set('Cookie', cookie);
headers.forEach(req.headers.set);
return req.close();
}
Future<String> body(HttpClientResponse r) => r.transform(utf8.decoder).join();
/// [source] 주소에서 틀린 코드로 한 번 (실제 연결 - 서버가 보는 상대 주소가 [source])
Future<void> wrongFrom(String source, String wrong) async {
final so = await Socket.connect('127.0.0.1', s.port, sourceAddress: source);
so.write('GET /?code=$wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n');
try {
await so.cast<List<int>>().transform(latin1.decoder).join();
} on SocketException {
// 30번째에서 공유가 꺼지며 끊길 수 있다
}
so.destroy();
}
Future<String> login() async {
final r = await get('/?code=${s.code}');
expect(r.statusCode, 303);
expect(r.headers.value('location'), '/', reason: '코드가 주소에 남지 않게');
await r.drain<void>();
return r.cookies.where((c) => c.name == 'jjshare').map((c) => '${c.name}=${c.value}').single;
}
/// HttpClient 는 ../ 를 미리 정리하므로 날 요청을 보낸다 (상태 코드 · 본문)
Future<(int, String)> raw(String path, String cookie) async {
final sock = await Socket.connect('127.0.0.1', s.port);
sock.write('GET $path HTTP/1.1\r\nHost: x\r\nCookie: $cookie\r\nConnection: close\r\n\r\n');
final text = await utf8.decoder.bind(sock).join();
sock.destroy();
return (int.parse(text.split(' ')[1]), text);
}
Future<int> rawStatus(String path, String cookie) async => (await raw(path, cookie)).$1;
test('코드: 6자리 · 없으면 401 · 틀리면 401 · 맞으면 쿠키로 목록', () async {
expect(s.code, matches(RegExp(r'^\d{6}$')));
var r = await get('/');
expect(r.statusCode, 401);
expect(await body(r), contains('name="code"'));
r = await get('/?code=${s.code == '000000' ? '111111' : '000000'}');
expect(r.statusCode, 401);
expect(await body(r), contains('코드가 맞지 않습니다'));
final cookie = await login();
r = await get('/', cookie: cookie);
expect(r.statusCode, 200);
final html = await body(r);
expect(html, contains('a.txt'));
expect(html, contains('한글 이름.srt'));
expect(html, contains('sub'));
expect(html, isNot(contains('secret.txt')));
expect(r.headers.value('content-security-policy'), contains("default-src 'none'"));
// 다른 쿠키 값은 안 됨
r = await get('/', cookie: 'jjshare=xxx');
expect(r.statusCode, 401);
await r.drain<void>();
});
test('읽기만: GET · HEAD 말고는 405', () async {
final cookie = await login();
for (final m in ['POST', 'PUT', 'DELETE', 'MKCOL', 'PROPFIND']) {
final r = await get('/a.txt', cookie: cookie, method: m);
expect(r.statusCode, 405, reason: m);
await r.drain<void>();
}
final r = await get('/a.txt', cookie: cookie, method: 'HEAD');
expect(r.statusCode, 200);
await r.drain<void>();
expect(File(p.join(shared.path, 'a.txt')).readAsStringSync(), '0123456789');
});
test('폴더 밖은 403 (../ · %2e%2e · 역슬래시 · 드라이브), 없는 것은 404', () async {
final cookie = await login();
// 서버가 경로의 ../ 를 먼저 정리하면 (공유 폴더 안의) 없는 이름이 되어 404, 정리되지 않은 것은 403 - 어느 쪽이든 밖은 열리지 않는다
for (final path in ['/../secret.txt', '/%2e%2e/secret.txt', '/sub/../../secret.txt', '/..%5csecret.txt', '/%2e%2e%5csecret.txt', '/C:%5cWindows']) {
final (code, text) = await raw(path, cookie);
expect(code, isIn([403, 404]), reason: path);
expect(text, isNot(contains('밖의 파일')), reason: path);
}
expect(await rawStatus('/..%5csecret.txt', cookie), 403, reason: '역슬래시는 정리되지 않으므로 막아야 함');
expect(await rawStatus('/C:%5cWindows', cookie), 403);
expect(await rawStatus('/없는것.txt', cookie), 404);
expect(await rawStatus('/sub/b.bin', cookie), 200);
});
test('링크 · junction 은 목록에도 없고 열면 403', () async {
final link = Link(p.join(shared.path, 'out'));
try {
link.createSync(tmp.path); // 공유 폴더 밖 (secret.txt 가 있는 곳)
} catch (_) {
markTestSkipped('링크를 만들 수 없음 (권한)');
return;
}
final cookie = await login();
final r = await get('/', cookie: cookie);
expect(await body(r), isNot(contains('>📁 out<')));
expect(await rawStatus('/out/secret.txt', cookie), 403);
expect(await rawStatus('/out/', cookie), 403);
});
test('파일: 한글 이름 · Range (동영상 건너뛰기) · 받기 · HTML 은 격리해 내려받기로', () async {
final cookie = await login();
var r = await get('/${Uri.encodeComponent('한글 이름.srt')}', cookie: cookie);
expect(r.statusCode, 200);
expect(await body(r), '자막');
r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=2-4'});
expect(r.statusCode, 206);
expect(r.headers.value('content-range'), 'bytes 2-4/10');
expect(await body(r), '234');
r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=-3'});
expect(await body(r), '789');
r = await get('/a.txt', cookie: cookie, headers: {'Range': 'bytes=50-'});
expect(r.statusCode, 416);
await r.drain<void>();
r = await get('/a.txt?dl=1', cookie: cookie);
expect(r.headers.value('content-disposition'), startsWith('attachment'));
await r.drain<void>();
r = await get('/page.html', cookie: cookie);
expect(r.headers.value('content-type'), 'application/octet-stream');
expect(r.headers.value('content-disposition'), startsWith('attachment'));
expect(r.headers.value('content-security-policy'), 'sandbox');
expect(r.headers.value('x-content-type-options'), 'nosniff');
await r.drain<void>();
});
test('코드를 5번 틀리면 1분 막고 (429), 모두 30번이면 공유를 끈다', () async {
final wrong = s.code == '000000' ? '111111' : '000000';
for (var i = 0; i < WifiShare.perClientFails; i++) {
final r = await get('/?code=$wrong');
expect(r.statusCode, 401);
await r.drain<void>();
}
var r = await get('/?code=${s.code}');
expect(r.statusCode, 429, reason: '막힌 동안은 맞는 코드도 안 됨');
await r.drain<void>();
// 모두 합쳐 30번: 다른 기기들처럼 다른 루프백 주소 (127.0.0.x) 에서 실제로 틀린 코드를 보낸다 (주소마다 막히기 전까지 4번)
for (var i = WifiShare.perClientFails; i < WifiShare.totalFails; i++) {
await wrongFrom('127.0.0.${2 + i ~/ 4}', wrong);
}
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(s.running, isFalse);
expect(s.stopReason, contains('너무 많이 틀려'));
expect(WifiShare.instance, isNull);
});
test('정한 시간이 지나면 저절로 꺼지고, 접속 기록에는 코드가 없다', () async {
final cookie = await login();
final r = await get('/a.txt', cookie: cookie);
await r.drain<void>();
// 기록은 응답을 다 보낸 뒤에 적힌다
for (var i = 0; i < 50 && !s.log.any((x) => x.path == '/a.txt'); i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(s.log.map((x) => x.path), contains('/a.txt'));
expect(s.log.any((x) => x.path.contains(s.code)), isFalse);
await s.stop();
final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), net: LoopbackNet());
expect(t.endsAt, isNotNull);
await Future<void>.delayed(const Duration(milliseconds: 500));
expect(t.running, isFalse);
expect(t.stopReason, contains('저절로'));
s = t;
});
test('같은 네트워크 판단: 사설망 · Tailscale 은 받고 공인 주소는 막는다', () {
for (final ip in ['192.168.0.5', '10.1.2.3', '172.16.0.1', '172.31.255.1', '100.69.29.106', '127.0.0.1', '169.254.1.1']) {
expect(WifiShare.isLocalNetwork(ip), isTrue, reason: ip);
}
for (final ip in ['8.8.8.8', '172.32.0.1', '100.128.0.1', '1.2.3.4', 'x']) {
expect(WifiShare.isLocalNetwork(ip), isFalse, reason: ip);
}
});
// 4273868 (wiki_tests) 에서 옮김 - 시험 망은 loopbackOnly 대신 지금의 주입 LoopbackNet
test('위키 974 2절 (10/20): [끄기] · 저절로 꺼진 뒤에는 그 주소 · 포트로 더는 열리지 않는다 (다른 기기에서 안 열림)', () async {
final port = s.port;
final ok = await Socket.connect('127.0.0.1', port);
ok.destroy();
await s.stop();
expect(s.running, isFalse);
await expectLater(Socket.connect('127.0.0.1', port, timeout: const Duration(seconds: 1)), throwsA(isA<SocketException>()),
reason: '[끄기] 뒤');
final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), net: LoopbackNet());
s = t;
final port2 = t.port;
(await Socket.connect('127.0.0.1', port2)).destroy();
for (var i = 0; i < 50 && t.running; i++) {
await Future<void>.delayed(const Duration(milliseconds: 20));
}
expect(t.running, isFalse);
await expectLater(Socket.connect('127.0.0.1', port2, timeout: const Duration(seconds: 1)), throwsA(isA<SocketException>()),
reason: '저절로 꺼진 뒤');
});
}