diff --git a/mobile/lib/features/school/undergraduate/navigation_policy.dart b/mobile/lib/features/school/undergraduate/navigation_policy.dart new file mode 100644 index 0000000..6a2dc28 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/navigation_policy.dart @@ -0,0 +1,22 @@ +import '../school_navigation_policy.dart'; + +class UndergraduateNavigationPolicy { + static bool canNavigate(Uri uri, {required bool isMainFrame}) => + uri.scheme == 'https' && + uri.port == 443 && + uri.userInfo.isEmpty && + const { + 'eams.uestc.edu.cn', + 'idas.uestc.edu.cn', + 'eportal.uestc.edu.cn', + }.contains(uri.host) || + !isMainFrame && uri.toString() == 'about:blank'; + static bool isDataPage(Uri uri) => + canNavigate(uri, isMainFrame: true) && + uri.host == 'eams.uestc.edu.cn' && + uri.path.startsWith('/eams/'); + static bool isLogout(Uri uri) => + isDataPage(uri) && uri.path == '/eams/logout.action'; + static Uri? secureIdentityRedirect(Uri uri) => + SchoolNavigationPolicy.secureIdentityRedirect(uri); +} diff --git a/mobile/lib/features/school/undergraduate/page_probe.dart b/mobile/lib/features/school/undergraduate/page_probe.dart new file mode 100644 index 0000000..3e45166 --- /dev/null +++ b/mobile/lib/features/school/undergraduate/page_probe.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'navigation_policy.dart'; + +const undergraduateProbeBase = r'''(() => { + const capture=__CAPTURE__; + const topUrl=location.href; + const result={topUrl,authenticated:false,pages:[]}; + const trusted=value=>{try{const u=new URL(value);return u.protocol==='https:' && u.hostname==='eams.uestc.edu.cn' && + (!u.port || u.port==='443') && !u.username && !u.password && u.pathname.startsWith('/eams/');}catch(_){return false;}}; + if(!trusted(topUrl) || document.querySelector('input[type="password"]'))return JSON.stringify(result); + const origin=new URL(topUrl).origin,seen=new Set();let total=0; + const headers=['课程序号','课程名称','考试日期','考试安排','考试地点','座位号','考试情况','考试类型','其它说明']; + const add=(kind,url,body)=>{ + total+=new TextEncoder().encode(body).byteLength; + if(total>2*1024*1024)throw new Error('capture_limit'); + result.pages.push({kind,url,html:body}); + }; + const visit=(doc,depth)=>{ + if(!doc || seen.has(doc))return; + let url;try{url=doc.location.href;}catch(_){return;} + if(!trusted(url) || new URL(url).origin!==origin || doc.querySelector('input[type="password"]'))return; + if(depth>4 || seen.size>=16)throw new Error('capture_limit');seen.add(doc); + const timetable=doc.querySelector('#manualArrangeCourseTable'); + const exams=Array.from(doc.querySelectorAll('table')).filter(table=>{ + const row=table.querySelector('tr');if(!row)return false; + const values=Array.from(row.children).map(c=>c.textContent.trim()); + return values.length===9 && headers.every((v,i)=>values[i]===v); + }); + const logout=Array.from(doc.querySelectorAll('a[href]')).some(a=>{try{return new URL(a.getAttribute('href'),url).pathname==='/eams/logout.action';}catch(_){return false;}}); + result.authenticated=result.authenticated || Boolean(logout || timetable || exams.length); + if(capture && (timetable || exams.length)){ + const fields=Array.from(doc.querySelectorAll('input[name="semester.id"],input[name="examType.id"],input[name="params"]')).map(input=>{ + const clone=input.cloneNode(true);clone.setAttribute('value',input.value);return clone.outerHTML; + }).join(''); + const scripts=Array.from(doc.querySelectorAll('script')).filter(s=>/new\s+TaskActivity|\bunitCount\s*=|\bvar\s+params\s*=/.test(s.textContent)).map(s=>s.outerHTML).join(''); + if(timetable){ + const roster=Array.from(doc.querySelectorAll('table')).filter(table=>{ + const row=table.querySelector('tr');return row && Array.from(row.children).some(c=>c.textContent.trim()==='课程序号') && + Array.from(row.children).some(c=>c.textContent.trim()==='课程代码'); + }).map(t=>t.outerHTML).join(''); + add('timetable',url,fields+timetable.outerHTML+roster+scripts); + } + for(const table of exams)add('exams',url,'
'+fields+'
'+table.outerHTML+scripts); + } + for(const frame of doc.querySelectorAll('iframe,frame')){ + let child;try{child=frame.contentDocument;}catch(_){continue;}visit(child,depth+1); + } + }; + try{visit(document,0);}catch(_){result.error='capture_limit';result.pages=[];} + return JSON.stringify(result); +})()'''; + +final undergraduateStatusProbeScript = undergraduateProbeBase.replaceAll( + '__CAPTURE__', + 'false', +); +final undergraduateCaptureScript = undergraduateProbeBase.replaceAll( + '__CAPTURE__', + 'true', +); + +class UndergraduateFragment { + const UndergraduateFragment(this.kind, this.url, this.html); + final String kind, html; + final Uri url; +} + +class UndergraduatePageEvidence { + const UndergraduatePageEvidence(this.authenticated, this.pages); + final bool authenticated; + final List pages; + static UndergraduatePageEvidence decode(Object raw, Uri current) { + try { + dynamic value = raw; + for (var i = 0; i < 2 && value is String; i++) { + value = jsonDecode(value); + } + if (value is! Map || + value['topUrl'] != current.toString() || + value['error'] != null || + !UndergraduateNavigationPolicy.canNavigate( + current, + isMainFrame: true, + )) + throw const FormatException('页面证据无效'); + final pages = value['pages']; + if (pages is! List || pages.length > 32) + throw const FormatException('页面片段过多'); + var bytes = 0; + final result = []; + for (final page in pages) { + if (page is! Map || + page['url'] is! String || + page['html'] is! String || + !const ['timetable', 'exams'].contains(page['kind'])) + throw const FormatException('页面片段无效'); + final uri = Uri.parse(page['url'] as String), + body = page['html'] as String; + bytes += utf8.encode(body).length; + if (bytes > 2 * 1024 * 1024 || + !UndergraduateNavigationPolicy.isDataPage(current) || + !UndergraduateNavigationPolicy.isDataPage(uri) || + current.origin != uri.origin) + throw const FormatException('页面来源或大小无效'); + result.add(UndergraduateFragment(page['kind'] as String, uri, body)); + } + if (!UndergraduateNavigationPolicy.isDataPage(current) && + (value['authenticated'] == true || result.isNotEmpty)) + throw const FormatException('认证页面不能作为数据来源'); + return UndergraduatePageEvidence( + value['authenticated'] == true, + List.unmodifiable(result), + ); + } on FormatException { + rethrow; + } catch (_) { + throw const FormatException('学校页面读取未完成'); + } + } +} diff --git a/mobile/test/school/undergraduate_page_probe_js_test.cjs b/mobile/test/school/undergraduate_page_probe_js_test.cjs new file mode 100644 index 0000000..ba76ed3 --- /dev/null +++ b/mobile/test/school/undergraduate_page_probe_js_test.cjs @@ -0,0 +1,35 @@ +const assert=require('node:assert/strict'),fs=require('node:fs'),vm=require('node:vm'),path=require('node:path'); +const source=fs.readFileSync(path.join(__dirname,'../../lib/features/school/undergraduate/page_probe.dart'),'utf8'); +const base=source.match(/undergraduateProbeBase = r'''([\s\S]*?)''';/)[1]; +const home='https://eams.uestc.edu.cn/eams/'; +function input(name,value='503') { + return {value,cloneNode(){return {stored:'523',setAttribute(k,v){this.stored=v;},get outerHTML(){return ``;}};}}; +} +function doc(url=home,{password=false,timetable=false,exams=false,frames=[],big=false}={}) { + const table={get outerHTML(){if(big)return '汉'.repeat(800000);return '课程
';}}; + const exam={querySelector(){return {children:['课程序号','课程名称','考试日期','考试安排','考试地点','座位号','考试情况','考试类型','其它说明'].map(textContent=>({textContent}))};},outerHTML:'考试
'}; + return {location:new URL(url),querySelector(s){ + if(s==='input[type="password"]')return password?{}:null; + if(s==='#manualArrangeCourseTable')return timetable?table:null; + if(s==='#stdExamTableForm')return exams?{}:null;return null; + },querySelectorAll(s){ + if(s==='iframe,frame')return frames; + if(s==='a[href]')return [{getAttribute(){return '/eams/logout.action';}}]; + if(s==='table')return exams?[exam]:[]; + if(s==='script')return [{textContent:'var unitCount = 12; activity = new TaskActivity("data");',outerHTML:''}]; + if(s==='input[name="semester.id"],input[name="examType.id"],input[name="params"]')return [input('semester.id'),input('examType.id','1')]; + return []; + }}; +} +function probe(document,capture=true){return JSON.parse(vm.runInNewContext(base.replaceAll('__CAPTURE__',String(capture)),{URL,TextEncoder,document,location:document.location}));} +assert.equal(probe(doc(home,{timetable:true}),false).pages.length,0,'status never captures timetable'); +assert.equal(probe(doc(home,{timetable:true}),false).authenticated,true); +const captured=probe(doc(home,{timetable:true})); +assert.equal(captured.pages.length,1);assert.match(captured.pages[0].html,/value="503"/);assert.doesNotMatch(captured.pages[0].html,/value="523"/); +assert.equal(probe(doc(home,{password:true,timetable:true})).pages.length,0); +assert.equal(probe(doc('https://idas.uestc.edu.cn/authserver/login',{timetable:true})).pages.length,0); +assert.equal(probe(doc(home,{frames:[{contentDocument:doc(home,{timetable:true})},{contentDocument:doc('https://other.test/eams/',{timetable:true})}]})).pages.length,1); +assert.equal(probe(doc(home,{timetable:true,big:true})).error,'capture_limit'); +assert.equal(probe(doc(home,{frames:Array.from({length:17},()=>({contentDocument:doc(home,{timetable:true})}))})).error,'capture_limit'); +assert.equal(probe(doc(home,{exams:true})).pages[0].kind,'exams'); +console.log('Undergraduate probe: status-only, actual values, same-origin frames, passwords and aggregate limits passed.'); diff --git a/mobile/test/school/undergraduate_page_probe_test.dart b/mobile/test/school/undergraduate_page_probe_test.dart new file mode 100644 index 0000000..620c36b --- /dev/null +++ b/mobile/test/school/undergraduate_page_probe_test.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/school/undergraduate/navigation_policy.dart'; +import 'package:hepan_everyday/features/school/undergraduate/page_probe.dart'; + +void main() { + final uri = Uri.parse('https://eams.uestc.edu.cn/eams/'); + test( + 'navigation and capture distinguish official data from authentication', + () { + for (final host in [ + 'eams.uestc.edu.cn', + 'idas.uestc.edu.cn', + 'eportal.uestc.edu.cn', + ]) { + expect( + UndergraduateNavigationPolicy.canNavigate( + Uri.https(host, '/'), + isMainFrame: true, + ), + isTrue, + ); + } + for (final value in [ + 'https://eams.uestc.edu.cn.attacker.test/eams/', + 'https://eams.uestc.edu.cn:444/eams/', + 'https://user@eams.uestc.edu.cn/eams/', + 'http://eams.uestc.edu.cn/eams/', + ]) { + expect( + UndergraduateNavigationPolicy.canNavigate( + Uri.parse(value), + isMainFrame: true, + ), + isFalse, + ); + } + expect(UndergraduateNavigationPolicy.isDataPage(uri), isTrue); + expect( + UndergraduateNavigationPolicy.isDataPage( + Uri.parse('https://idas.uestc.edu.cn/authserver/login'), + ), + isFalse, + ); + expect( + UndergraduateNavigationPolicy.secureIdentityRedirect( + Uri.parse('http://idas.uestc.edu.cn/authserver/login?service=abc'), + ).toString(), + 'https://idas.uestc.edu.cn/authserver/login?service=abc', + ); + }, + ); + test( + 'page evidence rejects changed URLs cross origins and aggregate oversize', + () { + Map value() => { + 'topUrl': uri.toString(), + 'authenticated': true, + 'pages': [ + {'kind': 'timetable', 'url': uri.toString(), 'html': 'table'}, + ], + }; + expect( + UndergraduatePageEvidence.decode(jsonEncode(value()), uri).pages, + hasLength(1), + ); + expect( + () => UndergraduatePageEvidence.decode({ + ...value(), + 'topUrl': 'https://other.test', + }, uri), + throwsFormatException, + ); + final cross = value(); + (cross['pages'] as List).single['url'] = 'https://idas.uestc.edu.cn/'; + expect( + () => UndergraduatePageEvidence.decode(cross, uri), + throwsFormatException, + ); + final large = value(); + (large['pages'] as List).single['html'] = '汉' * 800000; + expect( + () => UndergraduatePageEvidence.decode(large, uri), + throwsFormatException, + ); + expect( + () => UndergraduatePageEvidence.decode({ + ...value(), + 'error': 'capture_limit', + }, uri), + throwsFormatException, + ); + }, + ); +}