WIP 보안 검토 10 · 3 보충: Wi-Fi 공유는 디스크를 비동기로 · 설정 "Tailscale 에서도 열기" (검토 대기)
관리자 결정 (10/18): - 10: 요청마다 하던 경로 확인 (링크 · 폴더 밖) · 폴더 목록 · 파일 크기를 비동기 IO 로 (같은 망의 기기가 큰 폴더를 요청해도 화면 스레드가 멈칫하지 않게). 켤 때의 폴더 확인도. - 3 보충: Tailscale (100.64/10) 주소는 계속 열되, 공유 화면에 [Tailscale 에서도 열기] (처음 켬, wifiShareTailscale). 끄면 같은 Wi-Fi · 유선 주소에만 연다. 틀린 값은 처음 값 (켬). 시험: net_security_test (Tailscale 끔 · 처음 값 · 틀린 값) · wifi_share · wifi_share_page · settings_store 22 통과, analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>sec_fix_b
parent
8ca9184fdc
commit
19d4f7bf3d
|
|
@ -296,6 +296,7 @@
|
|||
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.",
|
||||
"TAESD (빠른 디코더)": "TAESD (fast decoder)",
|
||||
"TTL 만료": "TTL expired",
|
||||
"Tailscale 에서도 열기": "Also open on Tailscale",
|
||||
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor executable - GPL-enabled build)",
|
||||
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS does not accept the connection",
|
||||
"Tor SOCKS 포트를 찾지 못했습니다": "Could not find the Tor SOCKS port",
|
||||
|
|
@ -862,6 +863,7 @@
|
|||
"남은 시간 {0}": "{0} left",
|
||||
"남은 약 {0}": "about {0} left",
|
||||
"남은 용량": "free",
|
||||
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "Devices on your Tailscale network (tailnet) can also open it. When off, only the same Wi-Fi or wired network",
|
||||
"내려받기": "Download",
|
||||
"내려받기 (Windows 만)": "Download (Windows only)",
|
||||
"내려받기 실패 ({0})": "Download failed ({0})",
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@
|
|||
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。",
|
||||
"TAESD (빠른 디코더)": "TAESD (高速デコーダー)",
|
||||
"TTL 만료": "TTL 期限切れ",
|
||||
"Tailscale 에서도 열기": "Tailscale でも開く",
|
||||
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 実行ファイル - GPL を有効にしたビルド)",
|
||||
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS が接続を受け付けません",
|
||||
"Tor SOCKS 포트를 찾지 못했습니다": "Tor SOCKS ポートが見つかりませんでした",
|
||||
|
|
@ -862,6 +863,7 @@
|
|||
"남은 시간 {0}": "残り時間 {0}",
|
||||
"남은 약 {0}": "残り約 {0}",
|
||||
"남은 용량": "空き容量",
|
||||
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "自分の Tailscale ネットワーク (tailnet) の機器もこのアドレスで見られます。オフにすると同じ Wi-Fi・有線ネットワークのみ",
|
||||
"내려받기": "ダウンロード",
|
||||
"내려받기 (Windows 만)": "ダウンロード (Windows のみ)",
|
||||
"내려받기 실패 ({0})": "ダウンロード失敗({0})",
|
||||
|
|
|
|||
|
|
@ -296,6 +296,7 @@
|
|||
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。",
|
||||
"TAESD (빠른 디코더)": "TAESD (快速解码器)",
|
||||
"TTL 만료": "TTL 过期",
|
||||
"Tailscale 에서도 열기": "也在 Tailscale 上开放",
|
||||
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 可执行文件 - 启用 GPL 的构建)",
|
||||
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS 不接受连接",
|
||||
"Tor SOCKS 포트를 찾지 못했습니다": "未找到 Tor SOCKS 端口",
|
||||
|
|
@ -862,6 +863,7 @@
|
|||
"남은 시간 {0}": "剩余时间 {0}",
|
||||
"남은 약 {0}": "剩余约 {0}",
|
||||
"남은 용량": "剩余空间",
|
||||
"내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만": "你的 Tailscale 网络 (tailnet) 中的设备也能通过此地址查看。关闭后仅限同一 Wi-Fi 或有线网络",
|
||||
"내려받기": "下载",
|
||||
"내려받기 (Windows 만)": "下载 (仅 Windows)",
|
||||
"내려받기 실패 ({0})": "下载失败({0})",
|
||||
|
|
|
|||
|
|
@ -463,6 +463,9 @@ class AppSettings {
|
|||
/// 89: Wi-Fi 공유 - 저절로 끄는 시간 (분, 0 = 끄지 않음). 처음 값 30분 (관리자 지시 10/17)
|
||||
int wifiShareMinutes = 30;
|
||||
|
||||
/// 89 · 보안 검토 3: Wi-Fi 공유를 Tailscale (100.64/10, 내 tailnet 의 기기) 주소에서도 연다 (관리자 결정 10/18: 처음 켬)
|
||||
bool wifiShareTailscale = true;
|
||||
|
||||
/// 89: Wi-Fi 공유로 마지막에 고른 폴더 (다시 켤 때 처음 고른 것)
|
||||
String wifiShareFolder = '';
|
||||
|
||||
|
|
@ -814,6 +817,7 @@ class AppSettings {
|
|||
'explorerProgressHidden': explorerProgressHidden,
|
||||
'zipLevel': zipLevel,
|
||||
'wifiShareMinutes': wifiShareMinutes,
|
||||
'wifiShareTailscale': wifiShareTailscale,
|
||||
'wifiShareFolder': wifiShareFolder,
|
||||
'explorerSortPerFolder': explorerSortPerFolder,
|
||||
'explorerFolderSorts': explorerFolderSorts,
|
||||
|
|
@ -1049,6 +1053,7 @@ class AppSettings {
|
|||
..explorerProgressHidden = j['explorerProgressHidden'] == true
|
||||
..zipLevel = const [1, 6, 9].contains(j['zipLevel']) ? j['zipLevel'] as int : 6
|
||||
..wifiShareMinutes = const [0, 10, 30, 60, 120].contains(j['wifiShareMinutes']) ? j['wifiShareMinutes'] as int : 30
|
||||
..wifiShareTailscale = j['wifiShareTailscale'] is bool ? j['wifiShareTailscale'] as bool : true
|
||||
..wifiShareFolder = j['wifiShareFolder'] is String ? j['wifiShareFolder'] as String : ''
|
||||
..explorerDropAction = const ['ask', 'copy', 'move'].contains(j['explorerDropAction']) ? j['explorerDropAction'] as String : 'ask'
|
||||
..explorerFolderSorts = {
|
||||
|
|
|
|||
|
|
@ -80,15 +80,16 @@ class WifiShare extends ChangeNotifier {
|
|||
String urlWithCode(String host) => 'http://$host:$port/?code=$code';
|
||||
|
||||
/// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다
|
||||
static Future<WifiShare> start(String root, {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false}) async {
|
||||
static Future<WifiShare> start(String root,
|
||||
{Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false, bool tailscale = true}) async {
|
||||
await instance?.stop();
|
||||
final dir = Directory(root);
|
||||
if (!dir.existsSync()) throw FileSystemException(tr('폴더가 없습니다'), root);
|
||||
final real = dir.resolveSymbolicLinksSync();
|
||||
if (!await dir.exists()) throw FileSystemException(tr('폴더가 없습니다'), root);
|
||||
final real = await dir.resolveSymbolicLinks();
|
||||
final rng = random ?? Random.secure();
|
||||
final code = List.generate(6, (_) => rng.nextInt(10)).join();
|
||||
final loop = loopbackOnly || debugLoopbackOnly;
|
||||
final addrs = debugAddresses ?? await localAddresses();
|
||||
final addrs = debugAddresses ?? await localAddresses(tailscale: tailscale);
|
||||
// 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게
|
||||
if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다'));
|
||||
final binds = loop ? ['127.0.0.1'] : addrs;
|
||||
|
|
@ -156,13 +157,13 @@ class WifiShare extends ChangeNotifier {
|
|||
|
||||
/// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 -
|
||||
/// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음)
|
||||
static Future<List<String>> localAddresses() async {
|
||||
static Future<List<String>> localAddresses({bool tailscale = true}) async {
|
||||
try {
|
||||
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
|
||||
final all = [
|
||||
for (final i in list)
|
||||
for (final a in i.addresses)
|
||||
if (shareInterface(i.name, a.address)) a.address
|
||||
if (shareInterface(i.name, a.address, tailscale: tailscale)) a.address
|
||||
];
|
||||
int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3;
|
||||
return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b))));
|
||||
|
|
@ -172,10 +173,11 @@ class WifiShare extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
|
||||
static bool shareInterface(String name, String ip) {
|
||||
/// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기")
|
||||
static bool shareInterface(String name, String ip, {bool tailscale = true}) {
|
||||
final v = ip.split('.');
|
||||
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0;
|
||||
if (a == 100 && b >= 64 && b <= 127) return !isMobileInterfaceName(name);
|
||||
if (a == 100 && b >= 64 && b <= 127) return tailscale && !isMobileInterfaceName(name);
|
||||
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
|
||||
return isLanInterfaceName(name);
|
||||
}
|
||||
|
|
@ -193,8 +195,9 @@ class WifiShare extends ChangeNotifier {
|
|||
(a == 100 && b >= 64 && b <= 127);
|
||||
}
|
||||
|
||||
/// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null
|
||||
String? resolve(String rel) {
|
||||
/// [rel] (공유 폴더 기준, '/' 로 나눈 것) 의 실제 경로. 폴더 밖 · 링크 · 쓸 수 없는 이름이면 null.
|
||||
/// 보안 검토 10: 디스크는 비동기로 본다 (같은 망의 기기가 요청할 때마다 화면이 멈칫하지 않게)
|
||||
Future<String?> resolve(String rel) async {
|
||||
final segs = <String>[];
|
||||
for (final s in rel.split('/')) {
|
||||
if (s.isEmpty) continue;
|
||||
|
|
@ -205,11 +208,11 @@ class WifiShare extends ChangeNotifier {
|
|||
for (final s in segs) {
|
||||
at = p.join(at, s);
|
||||
// 링크 · junction 은 (가리키는 곳이 공유 폴더 안이어도) 따라가지 않는다
|
||||
if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.link) return null;
|
||||
if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.link) return null;
|
||||
}
|
||||
if (FileSystemEntity.typeSync(at, followLinks: false) == FileSystemEntityType.notFound) return null;
|
||||
if (await FileSystemEntity.type(at, followLinks: false) == FileSystemEntityType.notFound) return null;
|
||||
try {
|
||||
final real = FileSystemEntity.isDirectorySync(at) ? Directory(at).resolveSymbolicLinksSync() : File(at).resolveSymbolicLinksSync();
|
||||
final real = await FileSystemEntity.isDirectory(at) ? await Directory(at).resolveSymbolicLinks() : await File(at).resolveSymbolicLinks();
|
||||
if (!p.equals(real, _rootReal) && !p.isWithin(_rootReal, real)) return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
|
|
@ -218,13 +221,13 @@ class WifiShare extends ChangeNotifier {
|
|||
}
|
||||
|
||||
/// [resolve] 가 null 일 때: 막은 것 (폴더 밖 · 링크 · 쓸 수 없는 이름) 인지, 그냥 없는 것인지
|
||||
bool _blocked(String rel) {
|
||||
Future<bool> _blocked(String rel) async {
|
||||
var at = _rootReal;
|
||||
for (final s in rel.split('/')) {
|
||||
if (s.isEmpty) continue;
|
||||
if (s == '.' || s == '..' || s.contains('\\') || s.contains(':') || s.contains('\u0000')) return true;
|
||||
at = p.join(at, s);
|
||||
final t = FileSystemEntity.typeSync(at, followLinks: false);
|
||||
final t = await FileSystemEntity.type(at, followLinks: false);
|
||||
if (t == FileSystemEntityType.link) return true;
|
||||
if (t == FileSystemEntityType.notFound) return false;
|
||||
}
|
||||
|
|
@ -283,13 +286,13 @@ class WifiShare extends ChangeNotifier {
|
|||
return;
|
||||
}
|
||||
final rel = Uri.decodeComponent(path);
|
||||
final target = resolve(rel);
|
||||
final target = await resolve(rel);
|
||||
if (target == null) {
|
||||
status = _blocked(rel) ? 403 : 404;
|
||||
status = await _blocked(rel) ? 403 : 404;
|
||||
return await _text(res, status, status == 403 ? tr('공유한 폴더 밖이거나 링크라 열 수 없습니다') : tr('없는 항목입니다'));
|
||||
}
|
||||
if (FileSystemEntity.isDirectorySync(target)) {
|
||||
return await _page(res, 200, _listHtml(rel, target), head: req.method == 'HEAD');
|
||||
if (await FileSystemEntity.isDirectory(target)) {
|
||||
return await _page(res, 200, await _listHtml(rel, target), head: req.method == 'HEAD');
|
||||
}
|
||||
status = await _file(req, res, target);
|
||||
} catch (_) {
|
||||
|
|
@ -453,13 +456,19 @@ class WifiShare extends ChangeNotifier {
|
|||
'<form method="get" action="/"><input name="code" inputmode="numeric" autocomplete="off" maxlength="6" autofocus>'
|
||||
'<button type="submit">${_esc(tr('열기'))}</button></form></body></html>';
|
||||
|
||||
String _listHtml(String rel, String dir) {
|
||||
Future<String> _listHtml(String rel, String dir) async {
|
||||
final entries = <FileSystemEntity>[];
|
||||
final sizes = <String, int>{};
|
||||
try {
|
||||
for (final e in Directory(dir).listSync(followLinks: false)) {
|
||||
await for (final e in Directory(dir).list(followLinks: false)) {
|
||||
final n = p.basename(e.path);
|
||||
if (e is Link || isAppTempName(n) || isPartialFile(n)) continue; // 링크는 보이지도 않게
|
||||
entries.add(e);
|
||||
if (e is File) {
|
||||
try {
|
||||
sizes[e.path] = await e.length();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
entries.sort((a, b) {
|
||||
|
|
@ -481,10 +490,7 @@ class WifiShare extends ChangeNotifier {
|
|||
if (e is Directory) {
|
||||
b.write('<tr><td><a href="$href/">📁 ${_esc(n)}</a></td><td class="s"></td></tr>');
|
||||
} else {
|
||||
int size = 0;
|
||||
try {
|
||||
size = (e as File).lengthSync();
|
||||
} catch (_) {}
|
||||
final size = sizes[e.path] ?? 0;
|
||||
b.write('<tr><td><a href="$href">${_esc(n)}</a></td>'
|
||||
'<td class="s">${_esc(formatSize(size))} · <a href="$href?dl=1">${_esc(tr('받기'))}</a></td></tr>');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ class _WifiSharePageState extends State<WifiSharePage> {
|
|||
});
|
||||
try {
|
||||
final mins = widget.c.settings.wifiShareMinutes;
|
||||
final s = await WifiShare.start(widget.folder, autoOff: mins > 0 ? Duration(minutes: mins) : null);
|
||||
final s = await WifiShare.start(widget.folder,
|
||||
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.wifiShareTailscale);
|
||||
await widget.c.updateSettings((x) => x.wifiShareFolder = widget.folder);
|
||||
_s?.removeListener(_changed);
|
||||
_s = s..addListener(_changed);
|
||||
|
|
@ -124,6 +125,17 @@ class _WifiSharePageState extends State<WifiSharePage> {
|
|||
},
|
||||
),
|
||||
]),
|
||||
// 보안 검토 3 (관리자 결정 10/18): Tailscale 주소에서도 열지 (처음 켬)
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(tr('Tailscale 에서도 열기')),
|
||||
subtitle: Text(tr('내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만')),
|
||||
value: widget.c.settings.wifiShareTailscale,
|
||||
onChanged: (v) async {
|
||||
await widget.c.updateSettings((x) => x.wifiShareTailscale = v);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')),
|
||||
_note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -51,6 +52,12 @@ void main() {
|
|||
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 로 찾은 주소는 사설망만', () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue