WIP 새 네트워크 기능 보안 고침: 원격 이름 경로 벗어나기 · FTPS 주입 · Wi-Fi 공유는 Wi-Fi · 유선 주소에만 · 찾기 범위 · SMB 강제 DNS (검토 대기)
관리자 지시 2 (10/18, SFTP · SMB · FTP · Wi-Fi 공유 · 스트리밍 다리 · mDNS/훑기 검토) 의 고침:
1 (P0, 로컬 데이터) 악성 · 탈취된 서버가 목록 이름을 "..\..\x" 로 보내면 임시 사본이 앱 임시 폴더 밖에 쓰이고 맞추기 (--delete) 가 위 폴더를 지울 수 있었다
→ vList: 그 폴더 바로 아래의 평범한 이름만 (".." · "." · "\" · NUL · 다른 폴더 버림, 모든 원격 목록이 거침) · DavPath.parse 는 ".." 마디 거부 · vLocalCopy 는 임시 폴더 안인지 확인 (서버에 묻기 전에)
2 FTPS 명시: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 끊는다 (STARTTLS 주입)
3 Wi-Fi 공유: 0.0.0.0 이 아니라 화면에 보이는 Wi-Fi · 유선 · Tailscale 주소에만 따로 연다. 모바일 데이터 (rmnet · ccmni 등 - 통신사 10.x · 100.x) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 다른 VPN 은 뺀다.
주소가 없으면 켜지 않고 알림. 캡처에서도 172.24.240.1 (WSL) · 192.168.56.1 (VirtualBox) 이 주소 목록에 보였음.
4 SMB 찾기: Wi-Fi · 유선 연결만 훑는다 (isLanInterfaceName - 3 과 같은 기준)
5 SMB: 서버 이름을 FTP · SFTP 와 같은 DNS 설정 (강제 DNS) 으로 풀어 주소로 넘긴다 (Android 다리의 시스템 DNS 로 새지 않게)
8 FTP: \r · \n · NUL 이 든 명령은 보내지 않는다 (이름으로 명령 끼워 넣기)
11 mDNS: 사설망 주소만 받는다
15 [마스터 초기화] 확인 글에 SFTP · SMB · FTP · SFTP 키도 (실제로 지우는 것과 같게)
같이: Wi-Fi 공유 캡처 시험 (integration_test/windows_wifi_shot_test.dart) - 시험용 주소는 WifiShare.debugAddresses 로 (1차 캡처에서 화면이 다시 그려지지 않아 이 PC 의 실제 망 주소가 찍혔음)
남김 (보고서에): 6 SMB 서명 필수 · 암호화 안 될 때 표시 (Kotlin · 실기기), 7 FTPS 사설 CA 체인의 지문 고정 (사설 CA 서버 시험 필요), 10 Wi-Fi 공유 동기 IO.
괜찮음: 9 원격 임시 사본은 정리 창 "작업 임시 파일" 이 지움, 비밀 값 기록 · 설정 · 백업 · 오류 · 알림 · 경로 복사 없음, 인증서 · 호스트 키 건너뛰기 없음, 다리는 127.0.0.1 · 비밀 주소.
시험: test/net_security_test.dart (새 7) + 전체 902 통과 (17 건너뜀), analyze 문제 없음, l10n 빠짐 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sec_fix_b
parent
e8baa846bb
commit
4ae6845513
|
|
@ -380,6 +380,7 @@
|
|||
"Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "Only folders on this device can be shared over Wi-Fi",
|
||||
"Wi-Fi 공유를 껐습니다": "Wi-Fi sharing turned off",
|
||||
"Wi-Fi 공유를 켰습니다": "Wi-Fi sharing turned on",
|
||||
"Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "Can't turn on: not connected to Wi-Fi or a wired network",
|
||||
"Wi-Fi 로 공유": "Share over Wi-Fi",
|
||||
"Windows 가 쓰는 이름 ({0})": "Name reserved by Windows ({0})",
|
||||
"Windows 기본 브라우저": "Windows default browser",
|
||||
|
|
@ -1128,7 +1129,7 @@
|
|||
"마스터 비밀번호로 잠겨 있습니다": "Locked with the master password",
|
||||
"마스터 비밀번호를 끌까요?": "Turn off the master password?",
|
||||
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "Enter the master password to use saved passwords",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "Without the master password, saved passwords can't be used. So that they can't leak, the saved WebDAV and OpenSubtitles passwords and API keys are deleted and the master password is reset. The server list, user names and other settings are kept; you'll need to enter the passwords again.",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "Without the master password, saved passwords can't be used. So that they can't leak, the saved WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys are deleted and the master password is reset. The server list, user names and other settings are kept; you'll need to enter the passwords again.",
|
||||
"마스터 비밀번호를 묻는 때": "When to ask for the master password",
|
||||
"마스터 비밀번호를 초기화했습니다": "Reset the master password",
|
||||
"마스터 비밀번호와 같게 정할 수 없습니다": "Can't be the same as the master password",
|
||||
|
|
@ -1505,6 +1506,7 @@
|
|||
"서버 인증서를 확인할 수 없습니다": "Cannot verify the server certificate",
|
||||
"서버 저장 공간 부족": "server storage full",
|
||||
"서버 주소": "Server address",
|
||||
"서버 주소를 찾지 못했습니다 (DNS): {0}": "Couldn't find the server address (DNS): {0}",
|
||||
"서버 키 아직 확인 안 함": "Server key not checked yet",
|
||||
"서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)": "Server key: not checked yet ([Test connection] shows the fingerprint and asks)",
|
||||
"서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "The server asks to move to an https address ({0}). Change the server address to https://.",
|
||||
|
|
@ -1512,6 +1514,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "The server returned no image",
|
||||
"서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk",
|
||||
"서버가 이 기능을 허용하지 않음": "the server does not allow this",
|
||||
"서버에 닿을 수 없음": "host unreachable",
|
||||
|
|
@ -2052,6 +2055,7 @@
|
|||
"이름 일괄 바꾸기 · {0}개": "Batch rename · {0} items",
|
||||
"이름 틀": "Name pattern",
|
||||
"이름 틀 (확장자는 그대로)": "Name pattern (extension kept)",
|
||||
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "The name contains a line break and can't be sent over FTP",
|
||||
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
|
||||
"이름을 바꿉니다": "renames it",
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@
|
|||
"Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "Wi-Fi 共有できるのはこの端末のフォルダーだけです",
|
||||
"Wi-Fi 공유를 껐습니다": "Wi-Fi 共有をオフにしました",
|
||||
"Wi-Fi 공유를 켰습니다": "Wi-Fi 共有をオンにしました",
|
||||
"Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "Wi-Fi や有線ネットワークに接続されていないためオンにできません",
|
||||
"Wi-Fi 로 공유": "Wi-Fi で共有",
|
||||
"Windows 가 쓰는 이름 ({0})": "Windows が使う名前 ({0})",
|
||||
"Windows 기본 브라우저": "Windows の既定のブラウザー",
|
||||
|
|
@ -1128,7 +1129,7 @@
|
|||
"마스터 비밀번호로 잠겨 있습니다": "マスターパスワードでロックされています",
|
||||
"마스터 비밀번호를 끌까요?": "マスターパスワードをオフにしますか?",
|
||||
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "保存したパスワードを使うにはマスターパスワードを入力してください",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "マスターパスワードがわからないと保存したパスワードは使えません。パスワードが漏れないよう、保存した WebDAV・OpenSubtitles のパスワードと API キーを削除してマスターパスワードを初期化します。サーバー一覧・ID・その他の設定はそのままで、パスワードは再入力が必要です。",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "マスターパスワードがわからないと保存したパスワードは使えません。パスワードが漏れないよう、保存した WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キーと API キーを削除してマスターパスワードを初期化します。サーバー一覧・ID・その他の設定はそのままで、パスワードは再入力が必要です。",
|
||||
"마스터 비밀번호를 묻는 때": "マスターパスワードを尋ねるとき",
|
||||
"마스터 비밀번호를 초기화했습니다": "マスターパスワードを初期化しました",
|
||||
"마스터 비밀번호와 같게 정할 수 없습니다": "マスターパスワードと同じにはできません",
|
||||
|
|
@ -1505,6 +1506,7 @@
|
|||
"서버 인증서를 확인할 수 없습니다": "サーバー証明書を確認できません",
|
||||
"서버 저장 공간 부족": "サーバーの空き容量不足",
|
||||
"서버 주소": "サーバーアドレス",
|
||||
"서버 주소를 찾지 못했습니다 (DNS): {0}": "サーバーのアドレスが見つかりません (DNS): {0}",
|
||||
"서버 키 아직 확인 안 함": "サーバー鍵は未確認",
|
||||
"서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)": "サーバー鍵: 未確認 ([接続確認] を押すとフィンガープリントを表示して確認します)",
|
||||
"서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "サーバーが https アドレスへの移動を求めています ({0})。サーバー設定のアドレスを https:// に変更してください。",
|
||||
|
|
@ -1512,6 +1514,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "サーバーが画像を返しませんでした",
|
||||
"서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください",
|
||||
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
||||
"서버에 닿을 수 없음": "ホストに到達できません",
|
||||
|
|
@ -2052,6 +2055,7 @@
|
|||
"이름 일괄 바꾸기 · {0}개": "名前の一括変更 · {0} 件",
|
||||
"이름 틀": "名前の形式",
|
||||
"이름 틀 (확장자는 그대로)": "名前の形式 (拡張子はそのまま)",
|
||||
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名前に改行が含まれているため FTP で送れません",
|
||||
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
|
||||
"이름을 바꿉니다": "名前を変えます",
|
||||
|
|
|
|||
|
|
@ -380,6 +380,7 @@
|
|||
"Wi-Fi 공유는 이 기기의 폴더만 할 수 있습니다": "只能通过 Wi-Fi 共享本设备上的文件夹",
|
||||
"Wi-Fi 공유를 껐습니다": "已关闭 Wi-Fi 共享",
|
||||
"Wi-Fi 공유를 켰습니다": "已开启 Wi-Fi 共享",
|
||||
"Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다": "未连接到 Wi-Fi 或有线网络,无法开启",
|
||||
"Wi-Fi 로 공유": "通过 Wi-Fi 共享",
|
||||
"Windows 가 쓰는 이름 ({0})": "Windows 保留名称 ({0})",
|
||||
"Windows 기본 브라우저": "Windows 默认浏览器",
|
||||
|
|
@ -1128,7 +1129,7 @@
|
|||
"마스터 비밀번호로 잠겨 있습니다": "已用主密码锁定",
|
||||
"마스터 비밀번호를 끌까요?": "要关闭主密码吗?",
|
||||
"마스터 비밀번호를 넣어야 저장된 비밀번호를 쓸 수 있습니다": "需要输入主密码才能使用已保存的密码",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "不知道主密码就无法使用已保存的密码。为防止密码泄露,将删除已保存的 WebDAV · OpenSubtitles 密码和 API 密钥,并重置主密码。服务器列表 · 用户名 · 其他设置保持不变,需要重新输入密码。",
|
||||
"마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, 저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. 서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.": "不知道主密码就无法使用已保存的密码。为防止密码泄露,将删除已保存的 WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥,并重置主密码。服务器列表 · 用户名 · 其他设置保持不变,需要重新输入密码。",
|
||||
"마스터 비밀번호를 묻는 때": "何时询问主密码",
|
||||
"마스터 비밀번호를 초기화했습니다": "已重置主密码",
|
||||
"마스터 비밀번호와 같게 정할 수 없습니다": "不能与主密码相同",
|
||||
|
|
@ -1505,6 +1506,7 @@
|
|||
"서버 인증서를 확인할 수 없습니다": "无法验证服务器证书",
|
||||
"서버 저장 공간 부족": "服务器存储空间不足",
|
||||
"서버 주소": "服务器地址",
|
||||
"서버 주소를 찾지 못했습니다 (DNS): {0}": "找不到服务器地址 (DNS): {0}",
|
||||
"서버 키 아직 확인 안 함": "尚未确认服务器密钥",
|
||||
"서버 키: 아직 확인 안 함 ([연결 확인] 을 누르면 지문을 보이고 묻습니다)": "服务器密钥: 尚未确认 (点击 [测试连接] 会显示指纹并询问)",
|
||||
"서버가 https 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 https:// 로 바꾸세요.": "服务器要求改用 https 地址 ({0})。请将服务器设置中的地址改为 https://。",
|
||||
|
|
@ -1512,6 +1514,7 @@
|
|||
"서버가 그림을 돌려주지 않았습니다": "服务器没有返回图片",
|
||||
"서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应",
|
||||
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。",
|
||||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"",
|
||||
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
||||
"서버에 닿을 수 없음": "主机不可达",
|
||||
|
|
@ -2052,6 +2055,7 @@
|
|||
"이름 일괄 바꾸기 · {0}개": "批量重命名 · {0} 项",
|
||||
"이름 틀": "名称模式",
|
||||
"이름 틀 (확장자는 그대로)": "名称模式 (保留扩展名)",
|
||||
"이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다": "名称中含有换行,无法通过 FTP 发送",
|
||||
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
|
||||
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
|
||||
"이름을 바꿉니다": "重命名",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/wifi_share.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:jj_mkvmaker/ui/wifi_share_page.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/18): 89 Wi-Fi 공유 화면을 PNG 로 (JJ_SHOT_DIR) - 켜기 전 · 켠 뒤 (코드 · 주소 · QR · 남은 시간 · 접속 기록).
|
||||
/// 서버는 127.0.0.1 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이), 주소 줄은 시험용 사설 주소로 바꿔 보인다.
|
||||
/// 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_wifi_shot_test.dart -d windows
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_wifi');
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('Wi-Fi 공유 화면 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
WifiShare.debugLoopbackOnly = true;
|
||||
// 화면에는 이 PC 의 실제 주소 대신 시험용 사설 주소를 (캡처에 사무실 망 주소가 남지 않게)
|
||||
WifiShare.debugAddresses = const ['192.168.0.23'];
|
||||
addTearDown(() async {
|
||||
await WifiShare.instance?.stop();
|
||||
WifiShare.debugLoopbackOnly = false;
|
||||
WifiShare.debugAddresses = null;
|
||||
});
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_wifishot_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync();
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path)),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => b.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'wifi_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle();
|
||||
await shot('off');
|
||||
await t.tap(find.byIcon(Icons.wifi_tethering).first);
|
||||
for (var i = 0; i < 30 && WifiShare.instance == null; i++) {
|
||||
await settle(1);
|
||||
}
|
||||
await settle();
|
||||
await shot('on');
|
||||
await t.scrollUntilVisible(find.textContaining(RegExp(r'\(0\)')), 200, scrollable: find.byType(Scrollable).first);
|
||||
await shot('on_log');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,10 @@ class _Session {
|
|||
static void Function(String line)? debugLog;
|
||||
|
||||
void send(String cmd) {
|
||||
// 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다
|
||||
if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) {
|
||||
throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other);
|
||||
}
|
||||
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
|
||||
_raw.write(utf8.encode('$cmd\r\n'));
|
||||
}
|
||||
|
|
@ -274,6 +278,10 @@ class _Session {
|
|||
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
|
||||
FtpFailure.noTls);
|
||||
}
|
||||
// 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다
|
||||
if (_lines.isNotEmpty || _buf.isNotEmpty) {
|
||||
throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls);
|
||||
}
|
||||
final sub = _sub;
|
||||
_sub = null;
|
||||
_raw = await _secure(_raw, sub);
|
||||
|
|
|
|||
|
|
@ -10,10 +10,32 @@ class LanHost {
|
|||
const LanHost(this.address, this.name);
|
||||
}
|
||||
|
||||
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선 · Tailscale 은 빼고 - 100.64/10 은 넓어 훑지 않는다)
|
||||
/// 보안 검토 4: Wi-Fi · 유선 연결인지 (이름으로). 모바일 데이터 (통신사 망 - 다른 가입자) · 가상 어댑터 (WSL · Hyper-V ·
|
||||
/// VirtualBox · VMware · Docker) · VPN 은 아니다. Wi-Fi 공유 (89) 도 이것으로 고른다
|
||||
bool isLanInterfaceName(String name) {
|
||||
final n = name.toLowerCase();
|
||||
return !_mobileIf.hasMatch(n) && !_virtualIf.hasMatch(n);
|
||||
}
|
||||
|
||||
/// 모바일 데이터 (Android: rmnet · ccmni · pdp · v4-rmnet 등)
|
||||
bool isMobileInterfaceName(String name) => _mobileIf.hasMatch(name.toLowerCase());
|
||||
|
||||
final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet|clat)');
|
||||
final _virtualIf =
|
||||
RegExp(r'vethernet|hyper-v|virtualbox|vbox|vmware|vmnet|wsl|docker|^br-|^veth|virbr|^tun|^tap|^ppp|^ipsec|wireguard|^wg|zerotier|openvpn|tailscale');
|
||||
|
||||
/// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지
|
||||
bool isPrivateLanAddress(String ip) {
|
||||
final v = ip.split('.').map(int.tryParse).toList();
|
||||
if (v.length != 4 || v.any((x) => x == null || x < 0 || x > 255)) return false;
|
||||
return v[0] == 10 || (v[0] == 172 && v[1]! >= 16 && v[1]! <= 31) || (v[0] == 192 && v[1] == 168);
|
||||
}
|
||||
|
||||
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다)
|
||||
Future<List<InternetAddress>> localPrivateIPv4() async {
|
||||
final out = <InternetAddress>[];
|
||||
for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
||||
if (!isLanInterfaceName(ni.name)) continue;
|
||||
for (final a in ni.addresses) {
|
||||
final b = a.rawAddress;
|
||||
final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'dart:io';
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'lan_scan.dart' show LanHost;
|
||||
import 'lan_scan.dart' show LanHost, isPrivateLanAddress;
|
||||
|
||||
/// X-plore j 보충 (관리자 지시 10/18): mDNS 로 SMB 서버 찾기 (_smb._tcp.local) - 포트 훑기보다 먼저, 빠르게 (1~2초).
|
||||
/// "레거시 유니캐스트" 질의 (RFC 6762 6.7): 5353 이 아닌 빈 포트에서 224.0.0.251:5353 으로 묻고, 응답자가 그 포트로 바로 답한다 -
|
||||
|
|
@ -55,7 +55,8 @@ Future<List<LanHost>> findSmbServersMdns({Duration wait = const Duration(millise
|
|||
for (final inst in ptr) {
|
||||
final host = srv[inst];
|
||||
final ip = (host == null ? null : a[host.toLowerCase()]) ?? from[inst];
|
||||
if (ip == null || !seen.add(ip)) continue;
|
||||
// 보안 검토 11: 사설망 주소만 (가짜 응답이 바깥 주소를 이 망의 SMB 서버로 내세우지 않게)
|
||||
if (ip == null || !isPrivateLanAddress(ip) || !seen.add(ip)) continue;
|
||||
final label = host ?? inst.replaceFirst(RegExp(r'\._smb\._tcp\.local$', caseSensitive: false), '');
|
||||
out.add(LanHost(ip, label.endsWith('.') ? label.substring(0, label.length - 1) : label));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show InternetAddress;
|
||||
import 'dart:math' show min;
|
||||
import 'dart:typed_data';
|
||||
|
||||
|
|
@ -98,6 +99,10 @@ class SmbRemote implements RemoteClient {
|
|||
|
||||
/// 시험에서 가짜로 바꾼다
|
||||
static MethodChannel channel = const MethodChannel('jj_mkvmaker/smb');
|
||||
|
||||
/// 보안 검토 5: 서버 이름을 앱의 DNS 설정 (강제 DNS) 으로 푼다 - null 이면 그대로 넘겨 기기가 푼다 (주소 · 집 안 이름).
|
||||
/// main 에서 FTP 와 같은 것으로 바꾼다
|
||||
static Future<InternetAddress?> Function(String host) resolve = (host) async => null;
|
||||
static const chunk = 1 << 20;
|
||||
|
||||
Map<String, Object?> get _cfg => {
|
||||
|
|
@ -123,8 +128,15 @@ class SmbRemote implements RemoteClient {
|
|||
}
|
||||
if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage));
|
||||
if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword);
|
||||
// 보안 검토 5: 이름은 여기서 풀어 주소로 넘긴다 (Android 의 SMB 다리가 시스템 DNS 로 풀어 강제 DNS 를 비켜 가지 않게)
|
||||
String host;
|
||||
try {
|
||||
return await channel.invokeMethod<T>(method, {..._cfg, ...args});
|
||||
host = (await resolve(server.host.trim()))?.address ?? server.host.trim();
|
||||
} catch (e) {
|
||||
throw SmbException(trf('서버 주소를 찾지 못했습니다 (DNS): {0}', [e]), SmbFailure.unreachable);
|
||||
}
|
||||
try {
|
||||
return await channel.invokeMethod<T>(method, {..._cfg, 'host': host, ...args});
|
||||
} on PlatformException catch (e) {
|
||||
throw _explain(e, rel);
|
||||
} on MissingPluginException {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ class DavPath {
|
|||
rel = rel.replaceAll(RegExp(r'/+'), '/');
|
||||
if (rel.length > 1 && rel.endsWith('/')) rel = rel.substring(0, rel.length - 1);
|
||||
if (rel.isEmpty) rel = '/';
|
||||
// 보안 검토 1: ".." 마디는 받지 않는다 (서버 밖 · 로컬 임시 폴더 밖으로 나가지 않게)
|
||||
if (rel.split('/').any((s) => s == '..' || s.contains('\u0000'))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), path);
|
||||
return DavPath(server, rel, scheme);
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +175,9 @@ Future<List<({String path, bool isDir, int size, DateTime modified})>> vList(Str
|
|||
final d = DavPath.parse(dir);
|
||||
return [
|
||||
for (final i in await d.client.list(d.rel))
|
||||
// 보안 검토 1: 서버가 보낸 이름이 이 폴더 바로 아래의 평범한 이름일 때만 ("..", "/", "\" 가 든 이름은 버린다 -
|
||||
// 악성 서버가 "..\..\x" 같은 이름으로 이 기기의 다른 폴더에 쓰거나 맞추기 (--delete) 로 지우지 않게)
|
||||
if (remoteChildOk(d.rel, i.rel))
|
||||
(path: DavPath(d.server, i.rel, d.scheme).full, isDir: i.isDir, size: i.size, modified: i.modified),
|
||||
];
|
||||
}
|
||||
|
|
@ -288,14 +293,27 @@ Future<String> vUniqueTarget(String dir, String name) async {
|
|||
}
|
||||
}
|
||||
|
||||
/// 보안 검토 1: 원격 목록의 [child] 가 [parent] 바로 아래의 평범한 이름인지 (비지 않음 · "." · ".." 아님 · "/" "\" NUL 없음)
|
||||
bool remoteChildOk(String parent, String child) {
|
||||
final cut = child.lastIndexOf('/');
|
||||
if (cut < 0) return false;
|
||||
final name = child.substring(cut + 1);
|
||||
final dir = cut == 0 ? '/' : child.substring(0, cut);
|
||||
final want = parent.length > 1 && parent.endsWith('/') ? parent.substring(0, parent.length - 1) : parent;
|
||||
return dir == want && name.isNotEmpty && name != '.' && name != '..' && !name.contains('\\') && !name.contains('\u0000');
|
||||
}
|
||||
|
||||
/// 원격 파일을 열거나 재생하려면 로컬로 받아야 한다: 임시 폴더에 받은 경로 (로컬이면 그대로)
|
||||
Future<String> vLocalCopy(String path, String tempDir, {void Function(int done, int total)? onProgress}) async {
|
||||
if (!isDav(path)) return path;
|
||||
final d = DavPath.parse(path);
|
||||
final st = await d.client.stat(d.rel);
|
||||
final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : d.isFtp ? 'jj_ftp' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1))));
|
||||
await dir.create(recursive: true);
|
||||
final base = p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : d.isFtp ? 'jj_ftp' : 'jj_webdav');
|
||||
final dir = Directory(p.join(base, d.server, p.dirname(d.rel.substring(1))));
|
||||
final out = File(p.join(dir.path, d.name));
|
||||
// 보안 검토 1: 받은 파일은 늘 임시 폴더 안에 (서버 id · 경로가 어떻든)
|
||||
if (d.name.isEmpty || !p.isWithin(base, p.normalize(out.path))) throw FileSystemException(tr('쓸 수 없는 이름입니다'), path);
|
||||
final st = await d.client.stat(d.rel);
|
||||
await dir.create(recursive: true);
|
||||
// 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다
|
||||
if (st != null && await out.exists() && await out.length() == st.size && !(await out.lastModified()).isBefore(st.modified)) {
|
||||
return out.path;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
|
|||
|
||||
import '../l10n/tr.dart';
|
||||
import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile;
|
||||
import 'lan_scan.dart' show isLanInterfaceName, isMobileInterfaceName;
|
||||
|
||||
/// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
|
||||
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17):
|
||||
|
|
@ -40,13 +41,18 @@ class WifiShare extends ChangeNotifier {
|
|||
@visibleForTesting
|
||||
static bool debugLoopbackOnly = false;
|
||||
|
||||
/// 시험: 이 기기의 주소 대신 쓸 것 (화면 캡처에 시험 PC 의 실제 망 주소가 남지 않게)
|
||||
@visibleForTesting
|
||||
static List<String>? debugAddresses;
|
||||
|
||||
/// 화면에 보이는 접속 기록 개수
|
||||
static const maxLog = 200;
|
||||
|
||||
final String root;
|
||||
final String _rootReal;
|
||||
final String code;
|
||||
final HttpServer _server;
|
||||
/// 주소마다 하나 (보안 검토 3: 모든 연결 (0.0.0.0) 이 아니라 화면에 보인 Wi-Fi · 유선 주소에만 연다)
|
||||
final List<HttpServer> _servers;
|
||||
final Random _rng;
|
||||
final DateTime started;
|
||||
DateTime? endsAt;
|
||||
|
|
@ -65,9 +71,9 @@ class WifiShare extends ChangeNotifier {
|
|||
/// 이 기기의 주소들 (사설망 먼저). 화면 · QR 에 쓴다
|
||||
List<String> addresses = const [];
|
||||
|
||||
WifiShare._(this.root, this._rootReal, this.code, this._server, this._rng) : started = DateTime.now();
|
||||
WifiShare._(this.root, this._rootReal, this.code, this._servers, this._rng) : started = DateTime.now();
|
||||
|
||||
int get port => _server.port;
|
||||
int get port => _servers.first.port;
|
||||
bool get running => !_stopped;
|
||||
|
||||
/// 접속 주소 (코드를 넣은 것 - QR 용)
|
||||
|
|
@ -81,23 +87,44 @@ class WifiShare extends ChangeNotifier {
|
|||
final real = dir.resolveSymbolicLinksSync();
|
||||
final rng = random ?? Random.secure();
|
||||
final code = List.generate(6, (_) => rng.nextInt(10)).join();
|
||||
final any = loopbackOnly || debugLoopbackOnly ? InternetAddress.loopbackIPv4 : InternetAddress.anyIPv4;
|
||||
HttpServer server;
|
||||
final loop = loopbackOnly || debugLoopbackOnly;
|
||||
final addrs = debugAddresses ?? await localAddresses();
|
||||
// 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게
|
||||
if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다'));
|
||||
final binds = loop ? ['127.0.0.1'] : addrs;
|
||||
final servers = <HttpServer>[];
|
||||
final shown = <String>[];
|
||||
try {
|
||||
server = await HttpServer.bind(any, port);
|
||||
for (final a in binds) {
|
||||
final at = InternetAddress(a);
|
||||
try {
|
||||
servers.add(await HttpServer.bind(at, servers.isEmpty ? port : servers.first.port));
|
||||
} on SocketException {
|
||||
server = await HttpServer.bind(any, 0); // 쓰는 중이면 빈 포트
|
||||
if (servers.isNotEmpty) continue; // 같은 포트를 못 연 주소는 빼고 보인다
|
||||
servers.add(await HttpServer.bind(at, 0)); // 쓰는 중이면 빈 포트
|
||||
}
|
||||
final s = WifiShare._(root, real, code, server, rng);
|
||||
shown.add(a);
|
||||
}
|
||||
} catch (_) {
|
||||
for (final x in servers) {
|
||||
unawaited(x.close(force: true));
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
final s = WifiShare._(root, real, code, servers, rng);
|
||||
for (final server in servers) {
|
||||
server.autoCompress = false;
|
||||
server.defaultResponseHeaders.clear();
|
||||
server.serverHeader = null;
|
||||
s.addresses = await localAddresses();
|
||||
}
|
||||
s.addresses = loop ? addrs : shown;
|
||||
if (autoOff != null && autoOff > Duration.zero) {
|
||||
s.endsAt = s.started.add(autoOff);
|
||||
s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다')));
|
||||
}
|
||||
for (final server in servers) {
|
||||
server.listen(s._handle, onError: (_) {});
|
||||
}
|
||||
current.value = s;
|
||||
return s;
|
||||
}
|
||||
|
|
@ -110,9 +137,11 @@ class WifiShare extends ChangeNotifier {
|
|||
_timer?.cancel();
|
||||
_sessions.clear();
|
||||
if (identical(current.value, this)) current.value = null;
|
||||
for (final server in _servers) {
|
||||
try {
|
||||
await _server.close(force: true);
|
||||
await server.close(force: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
|
@ -125,11 +154,16 @@ class WifiShare extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저)
|
||||
/// 이 기기의 IPv4 주소 (이 기기 자신 제외, 사설망 먼저). Wi-Fi · 유선 · Tailscale 만 -
|
||||
/// 모바일 데이터 (통신사 망) · 가상 어댑터 (WSL · Hyper-V · VirtualBox · VMware · Docker) · 그 밖의 VPN 은 뺀다 (보안 검토 3, 캡처에서 찾음)
|
||||
static Future<List<String>> localAddresses() 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) a.address];
|
||||
final all = [
|
||||
for (final i in list)
|
||||
for (final a in i.addresses)
|
||||
if (shareInterface(i.name, a.address)) 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))));
|
||||
} catch (_) {
|
||||
|
|
@ -137,6 +171,15 @@ class WifiShare extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
|
||||
static bool shareInterface(String name, String ip) {
|
||||
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 == 169 && b == 254) return false; // 주소를 못 받은 연결
|
||||
return isLanInterfaceName(name);
|
||||
}
|
||||
|
||||
/// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지
|
||||
static bool isLocalNetwork(String ip) {
|
||||
final v = ip.split('.').map(int.tryParse).toList();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import 'core/app_update.dart';
|
|||
import 'core/playlist.dart';
|
||||
import 'core/sftp.dart' show SftpRegistry;
|
||||
import 'core/ftp.dart' show FtpRegistry;
|
||||
import 'core/smb.dart' show SmbRemote;
|
||||
import 'platform/android/android_download_tools.dart';
|
||||
import 'platform/android/android_keep_alive.dart';
|
||||
import 'platform/android/android_shell.dart' show applyScreenOrientation;
|
||||
|
|
@ -474,6 +475,7 @@ Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}
|
|||
// 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS)
|
||||
SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver);
|
||||
FtpRegistry.resolve = FtpRegistry.resolveWithDns(g.resolver);
|
||||
SmbRemote.resolve = FtpRegistry.resolveWithDns(g.resolver);
|
||||
if (second) return;
|
||||
TorService.dataDir = dataDir;
|
||||
TorService.instance.log = c.note;
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ Future<bool> forgotMaster(BuildContext context, AppController c, MasterLock lock
|
|||
context,
|
||||
title: tr('저장된 비밀번호를 지우고 마스터 초기화'),
|
||||
body: tr('마스터 비밀번호를 모르면 저장된 비밀번호를 쓸 수 없습니다. 비밀번호가 새어 나가지 않도록, '
|
||||
'저장된 WebDAV · OpenSubtitles 비밀번호와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
|
||||
'저장된 WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키와 API 키를 지우고 마스터 비밀번호를 초기화합니다. '
|
||||
'서버 목록 · 아이디 · 다른 설정은 그대로이며, 비밀번호는 다시 넣어야 합니다.'),
|
||||
ok: tr('지우고 초기화'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.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: '주소를 못 받은 연결');
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue