240 lines
11 KiB
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: '저절로 꺼진 뒤');
|
|
});
|
|
}
|