fix: recover authenticated undergraduate full pages with session guards
This commit is contained in:
@@ -27,6 +27,7 @@ android {
|
||||
// flag during build.
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "com.tpschedule.app.SchoolDocumentRenderInstrumentation"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -40,6 +41,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
testImplementation("junit:junit:4.12")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Instrumentation;
|
||||
import android.os.Bundle;
|
||||
import android.webkit.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import org.json.*;
|
||||
|
||||
/** Device-only check using public, local fixtures; no school requests or credentials. */
|
||||
public final class SchoolDocumentRenderInstrumentation extends Instrumentation {
|
||||
private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action";
|
||||
private WebView view;
|
||||
private volatile CountDownLatch loaded;
|
||||
private volatile String failureCode = "initial_fixture";
|
||||
private final AtomicInteger fixtures = new AtomicInteger();
|
||||
@Override public void onCreate(Bundle arguments) { super.onCreate(arguments); start(); }
|
||||
private void require(boolean value, String code) { if (!value) { failureCode = code; throw new AssertionError(code); } }
|
||||
private void waitFor(CountDownLatch latch) throws Exception { require(latch.await(12, TimeUnit.SECONDS), "fixture_timeout"); }
|
||||
private JSONObject evaluate(String expression) throws Exception {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicReference<String> result = new AtomicReference<>();
|
||||
runOnMainSync(() -> view.evaluateJavascript(expression, value -> { result.set(value); done.countDown(); }));
|
||||
waitFor(done);
|
||||
return new JSONObject(new JSONArray("[" + result.get() + "]").getString(0));
|
||||
}
|
||||
private boolean checkDocument(String nonce) throws Exception {
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
AtomicBoolean result = new AtomicBoolean();
|
||||
runOnMainSync(() -> SchoolDocumentRenderer.checkDocument(view, URL, nonce, valid -> { result.set(valid); done.countDown(); }));
|
||||
waitFor(done);
|
||||
return result.get();
|
||||
}
|
||||
@Override public void onStart() {
|
||||
Bundle result = new Bundle();
|
||||
int finishCode = Activity.RESULT_CANCELED;
|
||||
try {
|
||||
loaded = new CountDownLatch(1);
|
||||
runOnMainSync(() -> {
|
||||
view = new WebView(getTargetContext());
|
||||
view.getSettings().setJavaScriptEnabled(true);
|
||||
view.setWebViewClient(new WebViewClient() {
|
||||
@Override public void onPageFinished(WebView webView, String url) { loaded.countDown(); }
|
||||
@Override public WebResourceResponse shouldInterceptRequest(WebView webView, WebResourceRequest request) {
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null) path = "";
|
||||
String body = "";
|
||||
String mime = "text/plain";
|
||||
if (path.endsWith("fixture.js")) { body = "window.fixtureReady='external-script';"; mime = "application/javascript"; fixtures.incrementAndGet(); }
|
||||
if (path.endsWith("fixture.css")) { body = "#probe {color:rgb(12, 34, 56)}"; mime = "text/css"; fixtures.incrementAndGet(); }
|
||||
return new WebResourceResponse(mime,"UTF-8",new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
});
|
||||
CookieManager.getInstance().setCookie(URL, "SchoolDocumentFixture=public; Path=/eams; Secure");
|
||||
view.loadDataWithBaseURL(URL,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-old',enumerable:false})</script><body>old-public-fixture</body></html>","text/html","UTF-8",URL);
|
||||
});
|
||||
waitFor(loaded);
|
||||
require(checkDocument("fixture-old"), "current_document_rejected");
|
||||
loaded = new CountDownLatch(1);
|
||||
runOnMainSync(() -> view.loadDataWithBaseURL(URL,"<html><script>Object.defineProperty(window,'__tpscheduleDocumentToken',{value:'fixture-new',enumerable:false})</script><body>same-url-new-document</body></html>","text/html","UTF-8",URL));
|
||||
waitFor(loaded);
|
||||
require(!checkDocument("fixture-old"), "same_url_new_document_accepted");
|
||||
require(checkDocument("fixture-new"), "new_document_rejected");
|
||||
String html = "<!doctype html><html><head><meta charset='UTF-8'><link rel='stylesheet' href='fixture.css'><script src='fixture.js'></script></head><body><p id='probe'>电子科技大学 • 课表 ✓</p></body></html>";
|
||||
loaded = new CountDownLatch(1);
|
||||
AtomicBoolean applied = new AtomicBoolean();
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document(html, java.util.Collections.emptyList()))));
|
||||
require(applied.get(), "render_not_applied"); waitFor(loaded);
|
||||
JSONObject actual = evaluate("JSON.stringify({href:location.href,origin:location.origin,text:document.getElementById('probe').textContent,script:window.fixtureReady,color:getComputedStyle(document.getElementById('probe')).color})");
|
||||
require(URL.equals(actual.getString("href")), "visible_url_changed");
|
||||
require("https://eams.uestc.edu.cn".equals(actual.getString("origin")), "origin_changed");
|
||||
require("电子科技大学 • 课表 ✓".equals(actual.getString("text")), "unicode_changed");
|
||||
require("external-script".equals(actual.getString("script")), "external_script_missing");
|
||||
require("rgb(12, 34, 56)".equals(actual.getString("color")), "external_css_missing");
|
||||
require(fixtures.get() == 2, "relative_asset_resolution_failed");
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, "https://eams.uestc.edu.cn/eams/index.action", CookieManager.getInstance().getCookie(URL), new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList()))));
|
||||
require(!applied.get(), "stale_url_accepted");
|
||||
runOnMainSync(() -> applied.set(SchoolDocumentRenderer.render(view, URL, "SchoolDocumentFixture=wrong", new SchoolDocumentReader.Document("bad",java.util.Collections.emptyList()))));
|
||||
require(!applied.get(), "changed_session_accepted");
|
||||
JSONObject retained = evaluate("JSON.stringify({text:document.getElementById('probe').textContent})");
|
||||
require("电子科技大学 • 课表 ✓".equals(retained.getString("text")), "failed_render_changed_dom");
|
||||
result.putString("stream", "SchoolDocument render: URL/origin/Unicode/scripts/CSS/session/document guards passed\n");
|
||||
sendStatus(0,result);
|
||||
finishCode = Activity.RESULT_OK;
|
||||
} catch (Throwable ignored) {
|
||||
// Fixed output only: never print URI, cookie, page source or exception messages.
|
||||
result.putString("stream", "SchoolDocument render fixture failed: " + failureCode + "\n");
|
||||
sendStatus(-2,result);
|
||||
} finally {
|
||||
runOnMainSync(() -> { if (view != null) view.destroy(); });
|
||||
}
|
||||
finish(finishCode,result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/** UI-thread confined registry. Contents never cross the Flutter channel. */
|
||||
public final class SchoolDocumentPending<T> {
|
||||
private static final Semaphore SLOTS = new Semaphore(4);
|
||||
private static final long TTL = 20000;
|
||||
private static final class Entry<T> {
|
||||
final long viewId, created;
|
||||
final T value;
|
||||
Entry(long viewId, long created, T value) { this.viewId = viewId; this.created = created; this.value = value; }
|
||||
}
|
||||
private final LongSupplier clock;
|
||||
private final Map<String,Entry<T>> entries = new HashMap<>();
|
||||
private boolean closed;
|
||||
public SchoolDocumentPending(LongSupplier clock) { this.clock = clock; }
|
||||
public String put(long viewId, T value) {
|
||||
expire();
|
||||
if (closed) return null;
|
||||
Iterator<Entry<T>> old = entries.values().iterator();
|
||||
while (old.hasNext()) if (old.next().viewId == viewId) { old.remove(); SLOTS.release(); }
|
||||
if (!SLOTS.tryAcquire()) return null;
|
||||
String token = UUID.randomUUID().toString();
|
||||
entries.put(token, new Entry<>(viewId, clock.getAsLong(), value));
|
||||
return token;
|
||||
}
|
||||
public T get(String token) {
|
||||
expire();
|
||||
Entry<T> entry = entries.get(token);
|
||||
return entry == null ? null : entry.value;
|
||||
}
|
||||
public T remove(String token) {
|
||||
Entry<T> removed = entries.remove(token);
|
||||
if (removed == null) return null;
|
||||
SLOTS.release();
|
||||
return removed.value;
|
||||
}
|
||||
public void removeView(long viewId) {
|
||||
Iterator<Entry<T>> current = entries.values().iterator();
|
||||
while (current.hasNext()) if (current.next().viewId == viewId) { current.remove(); SLOTS.release(); }
|
||||
}
|
||||
private void expire() {
|
||||
long now = clock.getAsLong();
|
||||
Iterator<Entry<T>> current = entries.values().iterator();
|
||||
while (current.hasNext()) if (now - current.next().created >= TTL) { current.remove(); SLOTS.release(); }
|
||||
}
|
||||
public void close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
SLOTS.release(entries.size());
|
||||
entries.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.regex.*;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/** Bounded transport for an already visible read-only school document. */
|
||||
public final class SchoolDocumentReader {
|
||||
public interface ConnectionFactory { HttpURLConnection open(URL url) throws IOException; }
|
||||
public static final class Failure extends Exception {
|
||||
public final String code;
|
||||
public Failure(String code) { super(code); this.code = code; }
|
||||
}
|
||||
public static final class Document {
|
||||
public final String html;
|
||||
public final List<String> setCookies;
|
||||
public Document(String html, List<String> setCookies) { this.html = html; this.setCookies = setCookies; }
|
||||
}
|
||||
private final ConnectionFactory connections;
|
||||
public SchoolDocumentReader(ConnectionFactory connections) { this.connections = connections; }
|
||||
public static URI validateUrl(String value) throws Failure {
|
||||
try {
|
||||
if (value == null || value.length() > 8192) throw new Failure("unsafe_url");
|
||||
URI uri = new URI(value);
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null ||
|
||||
(uri.getPort() != -1 && uri.getPort() != 443)) throw new Failure("unsafe_url");
|
||||
String path = uri.getRawPath();
|
||||
boolean undergraduate = "eams.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null &&
|
||||
path.matches("/eams/?|/eams/(home(?:Ext)?(?:!(?:main|index|welcome))?|index|courseTableForStd(?:!courseTable)?|stdExamTable!examTable)\\.action");
|
||||
boolean graduate = "yjsjy.uestc.edu.cn".equalsIgnoreCase(uri.getHost()) && path != null &&
|
||||
path.matches("/pyxx/pygl/(xskbcx/index/[1-9][0-9]*|kskcxx/index)");
|
||||
if (!undergraduate && !graduate) throw new Failure("unsafe_url");
|
||||
if (uri.getRawQuery() != null) {
|
||||
for (String parameter : uri.getRawQuery().split("[&;]")) {
|
||||
String key = URLDecoder.decode(parameter.split("=", 2)[0], "UTF-8").toLowerCase(Locale.ROOT);
|
||||
if (key.matches("x-requested-with|ticket|password|username|j_username|j_password|action|optype|operation|method|command|redirect|redirect_uri|service"))
|
||||
throw new Failure("unsafe_url");
|
||||
}
|
||||
}
|
||||
return uri;
|
||||
} catch (Failure failure) { throw failure; }
|
||||
catch (Exception ignored) { throw new Failure("unsafe_url"); }
|
||||
}
|
||||
|
||||
public Document fetch(String url, String cookie, String userAgent) throws Failure {
|
||||
URI uri = validateUrl(url);
|
||||
if (cookie == null || cookie.trim().isEmpty() || cookie.length() > 16384 || hasControls(cookie)) throw new Failure("no_session");
|
||||
HttpURLConnection connection = null;
|
||||
long deadline = System.nanoTime() + 15_000_000_000L;
|
||||
try {
|
||||
connection = connections.open(uri.toURL());
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(8000);
|
||||
connection.setReadTimeout(8000);
|
||||
connection.setUseCaches(false);
|
||||
connection.setRequestProperty("Cookie", cookie);
|
||||
if (userAgent != null && userAgent.length() <= 4096 && !hasControls(userAgent)) connection.setRequestProperty("User-Agent", userAgent);
|
||||
connection.setRequestProperty("Accept", "text/html,application/xhtml+xml");
|
||||
connection.setRequestProperty("Accept-Encoding", "gzip");
|
||||
if (connection.getResponseCode() != 200) throw new Failure("invalid_response");
|
||||
String contentType = connection.getContentType();
|
||||
if (contentType == null) throw new Failure("invalid_response");
|
||||
String mime = contentType.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||
if (!mime.equals("text/html") && !mime.equals("application/xhtml+xml")) throw new Failure("invalid_response");
|
||||
Charset charset = StandardCharsets.UTF_8;
|
||||
Matcher declared = Pattern.compile("(?:^|;)\\s*charset\\s*=\\s*[\"']?([^;\\s\"']+)", Pattern.CASE_INSENSITIVE).matcher(contentType);
|
||||
if (declared.find()) {
|
||||
try { charset = Charset.forName(declared.group(1)); }
|
||||
catch (Exception ignored) { throw new Failure("invalid_response"); }
|
||||
}
|
||||
String encoding = connection.getContentEncoding();
|
||||
if (encoding != null && !encoding.equalsIgnoreCase("gzip") && !encoding.equalsIgnoreCase("identity")) throw new Failure("invalid_response");
|
||||
byte[] bytes;
|
||||
try (InputStream raw = new BoundedInput(connection.getInputStream(), deadline);
|
||||
InputStream decoded = "gzip".equalsIgnoreCase(encoding) ? new GZIPInputStream(raw) : raw;
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = decoded.read(buffer)) != -1) {
|
||||
if (System.nanoTime() > deadline) throw new Failure("network_failed");
|
||||
if (body.size() + count > 2 * 1024 * 1024) throw new Failure("too_large");
|
||||
body.write(buffer, 0, count);
|
||||
}
|
||||
bytes = body.toByteArray();
|
||||
}
|
||||
return new Document(new String(bytes, charset), safeCookies(uri, connection.getHeaderFields()));
|
||||
} catch (Failure failure) { throw failure; }
|
||||
catch (BoundFailure failure) { throw new Failure(failure.code); }
|
||||
catch (Exception ignored) { throw new Failure("network_failed"); }
|
||||
finally { if (connection != null) connection.disconnect(); }
|
||||
}
|
||||
|
||||
private static final class BoundFailure extends IOException {
|
||||
final String code;
|
||||
BoundFailure(String code) { super(code); this.code = code; }
|
||||
}
|
||||
/** Also bounds gzip metadata, which the decoder may read before emitting bytes. */
|
||||
private static final class BoundedInput extends FilterInputStream {
|
||||
final long deadline;
|
||||
int total;
|
||||
BoundedInput(InputStream source, long deadline) { super(source); this.deadline = deadline; }
|
||||
private void check(int count) throws BoundFailure {
|
||||
if (System.nanoTime() > deadline) throw new BoundFailure("network_failed");
|
||||
if (count > 0) total += count;
|
||||
if (total > 2 * 1024 * 1024) throw new BoundFailure("too_large");
|
||||
}
|
||||
@Override public int read() throws IOException {
|
||||
check(0); int result = in.read(); check(result < 0 ? 0 : 1); return result;
|
||||
}
|
||||
@Override public int read(byte[] bytes, int offset, int length) throws IOException {
|
||||
check(0); int count = in.read(bytes, offset, length); check(count); return count;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasControls(String value) {
|
||||
for (int i = 0; i < value.length(); i++) if (value.charAt(i) < 32 || value.charAt(i) == 127) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<String> safeCookies(URI uri, Map<String,List<String>> headers) {
|
||||
List<String> accepted = new ArrayList<>();
|
||||
int total = 0;
|
||||
if (headers == null) return accepted;
|
||||
String root = uri.getHost().equalsIgnoreCase("eams.uestc.edu.cn") ? "/eams" : "/pyxx";
|
||||
for (Map.Entry<String,List<String>> entry : headers.entrySet()) {
|
||||
if (entry.getKey() == null || !entry.getKey().equalsIgnoreCase("Set-Cookie") || entry.getValue() == null) continue;
|
||||
for (String cookie : entry.getValue()) {
|
||||
if (cookie == null || cookie.length() > 4096 || hasControls(cookie)) continue;
|
||||
String[] parts = cookie.split(";", -1);
|
||||
if (!parts[0].trim().matches("[!#$%&'*+.^_`|~0-9A-Za-z-]+=[^;]*")) continue;
|
||||
boolean safe = true, domainSeen = false, pathSeen = false;
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
String[] attr = parts[i].trim().split("=", 2);
|
||||
if (attr[0].equalsIgnoreCase("Domain")) {
|
||||
if (domainSeen || attr.length != 2 || !attr[1].equalsIgnoreCase(uri.getHost())) safe = false;
|
||||
domainSeen = true;
|
||||
}
|
||||
if (attr[0].equalsIgnoreCase("Path")) {
|
||||
if (pathSeen || attr.length != 2 || !(attr[1].equals(root) || attr[1].startsWith(root+"/")) || attr[1].contains("%") || attr[1].contains("\\") || attr[1].contains("..")) safe = false;
|
||||
pathSeen = true;
|
||||
}
|
||||
}
|
||||
if (safe && accepted.size() < 16 && total + cookie.length() <= 16384) {
|
||||
accepted.add(cookie); total += cookie.length();
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(accepted);
|
||||
}
|
||||
|
||||
static List<String> cookiesForRender(String url, List<String> cookies) throws Failure {
|
||||
return safeCookies(validateUrl(url), Collections.singletonMap("Set-Cookie", cookies));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/** Same-turn final guards and WebView load, shared with device instrumentation. */
|
||||
public final class SchoolDocumentRenderer {
|
||||
private SchoolDocumentRenderer() { }
|
||||
/** Checks the live document; callers must recheck native state in this callback. */
|
||||
public static void checkDocument(WebView view, String url, String documentToken, ValueCallback<Boolean> callback) {
|
||||
if (Looper.myLooper() != Looper.getMainLooper() || view == null || documentToken == null ||
|
||||
!documentToken.matches("[A-Za-z0-9-]{1,128}")) { callback.onReceiveValue(false); return; }
|
||||
try {
|
||||
SchoolDocumentReader.validateUrl(url);
|
||||
view.evaluateJavascript("location.href === " + JSONObject.quote(url) +
|
||||
" && window.__tpscheduleDocumentToken === " + JSONObject.quote(documentToken),
|
||||
value -> callback.onReceiveValue("true".equals(value)));
|
||||
} catch (Exception ignored) { callback.onReceiveValue(false); }
|
||||
}
|
||||
public static boolean render(WebView view, String url, String originalCookie, SchoolDocumentReader.Document document) {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) return false;
|
||||
try {
|
||||
SchoolDocumentReader.validateUrl(url);
|
||||
if (view == null || document == null || document.html == null || document.html.length() > 2 * 1024 * 1024 ||
|
||||
originalCookie == null || originalCookie.trim().isEmpty() || !url.equals(view.getUrl())) return false;
|
||||
CookieManager cookies = CookieManager.getInstance();
|
||||
if (!originalCookie.equals(cookies.getCookie(url))) return false;
|
||||
// The guards, scoped cookie application and load occur in one UI turn.
|
||||
for (String cookie : SchoolDocumentReader.cookiesForRender(url, document.setCookies)) cookies.setCookie(url, cookie);
|
||||
view.loadDataWithBaseURL(url, document.html, "text/html", "UTF-8", url);
|
||||
return true;
|
||||
} catch (Exception ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var calendar: CalendarBridge? = null
|
||||
private var reminders: ReminderSystemBridge? = null
|
||||
private var schoolDocuments: SchoolDocumentBridge? = null
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -20,6 +21,13 @@ class MainActivity : FlutterActivity() {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
schoolDocuments?.close()
|
||||
schoolDocuments = SchoolDocumentBridge(flutterEngine)
|
||||
}
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
schoolDocuments?.close()
|
||||
schoolDocuments = null
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
@@ -28,6 +36,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun onDestroy() {
|
||||
calendar?.close()
|
||||
reminders?.close()
|
||||
schoolDocuments?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.tpschedule.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugins.webviewflutter.WebViewFlutterAndroidExternalApi
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.concurrent.ArrayBlockingQueue
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.ThreadPoolExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
/** Fetches a bounded full document with the visible WebView's existing session. */
|
||||
class SchoolDocumentBridge(private val engine: FlutterEngine) {
|
||||
private class Candidate(
|
||||
val viewId: Long,
|
||||
val url: String,
|
||||
val cookie: String,
|
||||
val documentToken: String,
|
||||
val view: WeakReference<WebView>,
|
||||
var document: SchoolDocumentReader.Document? = null,
|
||||
)
|
||||
|
||||
private val ui = Handler(Looper.getMainLooper())
|
||||
private val worker = ThreadPoolExecutor(
|
||||
2, 2, 0L, TimeUnit.MILLISECONDS, ArrayBlockingQueue<Runnable>(2),
|
||||
)
|
||||
private val pending = SchoolDocumentPending<Candidate> { SystemClock.elapsedRealtime() }
|
||||
private val reader = SchoolDocumentReader { url -> url.openConnection() as HttpsURLConnection }
|
||||
private val channel = MethodChannel(engine.dartExecutor.binaryMessenger, "com.tpschedule.app/school_document")
|
||||
private var closed = false
|
||||
|
||||
init {
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
try {
|
||||
if (closed || Looper.myLooper() != Looper.getMainLooper()) {
|
||||
fail(result, "unavailable")
|
||||
} else {
|
||||
when (call.method) {
|
||||
"fetch" -> fetch(call, result)
|
||||
"render" -> render(call, result)
|
||||
"invalidate" -> {
|
||||
val viewId = call.argument<Number>("viewId")?.toLong()
|
||||
if (viewId != null) pending.removeView(viewId)
|
||||
result.success(null)
|
||||
}
|
||||
"discard" -> {
|
||||
call.argument<String>("token")?.let { pending.remove(it) }
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
} catch (failure: SchoolDocumentReader.Failure) {
|
||||
fail(result, failure.code)
|
||||
} catch (_: Exception) {
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun webView(viewId: Long): WebView? =
|
||||
WebViewFlutterAndroidExternalApi.getWebView(engine, viewId)
|
||||
|
||||
private fun fetch(call: MethodCall, result: MethodChannel.Result) {
|
||||
val viewId = call.argument<Number>("viewId")?.toLong()
|
||||
?: return fail(result, "unavailable")
|
||||
val url = call.argument<String>("url") ?: return fail(result, "unsafe_url")
|
||||
val documentToken = call.argument<String>("documentToken")
|
||||
?.takeIf { it.matches(Regex("[A-Za-z0-9-]{1,128}")) }
|
||||
?: return fail(result, "stale_document")
|
||||
SchoolDocumentReader.validateUrl(url)
|
||||
val view = webView(viewId) ?: return fail(result, "unavailable")
|
||||
if (view.url != url) return fail(result, "stale_document")
|
||||
val cookie = CookieManager.getInstance().getCookie(url)
|
||||
if (cookie.isNullOrBlank()) return fail(result, "no_session")
|
||||
val userAgent = view.settings.userAgentString
|
||||
val candidate = Candidate(viewId, url, cookie, documentToken, WeakReference(view))
|
||||
val token = pending.put(viewId, candidate) ?: return fail(result, "unavailable")
|
||||
// Expiration clears native bodies/cookies even when Dart never calls discard.
|
||||
ui.postDelayed({ pending.remove(token) }, 20_000)
|
||||
try {
|
||||
worker.execute {
|
||||
var document: SchoolDocumentReader.Document? = null
|
||||
var error: String? = null
|
||||
try {
|
||||
document = reader.fetch(url, cookie, userAgent)
|
||||
} catch (failure: SchoolDocumentReader.Failure) {
|
||||
error = failure.code
|
||||
} catch (_: Exception) {
|
||||
error = "network_failed"
|
||||
}
|
||||
ui.post {
|
||||
try {
|
||||
if (closed) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
} else if (pending.get(token) !== candidate || !matches(candidate)) {
|
||||
pending.remove(token)
|
||||
fail(result, "stale_document")
|
||||
} else if (error != null || document == null) {
|
||||
pending.remove(token)
|
||||
fail(result, error ?: "invalid_response")
|
||||
} else {
|
||||
candidate.document = document
|
||||
result.success(mapOf("token" to token, "html" to document!!.html))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: RejectedExecutionException) {
|
||||
pending.remove(token)
|
||||
fail(result, "unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
private fun matches(candidate: Candidate): Boolean {
|
||||
val actual = webView(candidate.viewId) ?: return false
|
||||
return actual === candidate.view.get() && actual.url == candidate.url &&
|
||||
CookieManager.getInstance().getCookie(candidate.url) == candidate.cookie
|
||||
}
|
||||
|
||||
private fun render(call: MethodCall, result: MethodChannel.Result) {
|
||||
val token = call.argument<String>("token") ?: return result.success(false)
|
||||
val candidate = pending.get(token) ?: return result.success(false)
|
||||
val document = candidate.document ?: return result.success(false)
|
||||
if (!matches(candidate)) return result.success(false)
|
||||
val view = candidate.view.get() ?: return result.success(false)
|
||||
SchoolDocumentRenderer.checkDocument(view, candidate.url, candidate.documentToken) { sameDocument ->
|
||||
try {
|
||||
// Invalidation, replacement or expiration during JS verification cancels this render.
|
||||
if (closed || !sameDocument || pending.get(token) !== candidate || !matches(candidate)) {
|
||||
pending.remove(token)
|
||||
result.success(false)
|
||||
} else {
|
||||
pending.remove(token)
|
||||
result.success(SchoolDocumentRenderer.render(view, candidate.url, candidate.cookie, document))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pending.remove(token)
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fail(result: MethodChannel.Result, code: String) = result.error(code, code, null)
|
||||
|
||||
fun close() {
|
||||
closed = true
|
||||
channel.setMethodCallHandler(null)
|
||||
pending.close()
|
||||
worker.shutdownNow()
|
||||
ui.removeCallbacksAndMessages(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SchoolDocumentPendingTest {
|
||||
@Test public void invalidateViewRemovesCandidateAwaitingDocumentVerification() {
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> 0);
|
||||
try {
|
||||
String waiting = store.put(42, "awaiting-verification"), other = store.put(43, "other-view");
|
||||
store.removeView(42);
|
||||
assertNull(store.get(waiting));
|
||||
assertEquals("other-view", store.get(other));
|
||||
assertNotNull(store.put(42, "new-document"));
|
||||
assertNull(store.get(waiting));
|
||||
} finally { store.close(); }
|
||||
}
|
||||
@Test public void capsPendingAcrossViewsAndReleasesOnDiscard() {
|
||||
long[] now = {0};
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> now[0]);
|
||||
for (int i=0;i<4;i++) assertNotNull(store.put(i, "doc"));
|
||||
assertNull(store.put(5, "over-cap"));
|
||||
String removed = store.put(0, "replacement");
|
||||
assertNotNull(removed);
|
||||
store.remove(removed);
|
||||
assertNotNull(store.put(5, "after-discard"));
|
||||
store.close();
|
||||
}
|
||||
@Test public void expiresAtTwentySecondsAndDoesNotReviveAfterClose() {
|
||||
long[] now = {100};
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> now[0]);
|
||||
String token = store.put(42, "private");
|
||||
now[0] = 20099; assertEquals("private", store.get(token));
|
||||
now[0] = 20100; assertNull(store.get(token));
|
||||
String next = store.put(43, "private"); store.close();
|
||||
assertNull(store.get(next)); assertNull(store.put(44, "late"));
|
||||
}
|
||||
@Test public void newFetchReplacesOnlyTheSameViewAndInvalidatesOldToken() {
|
||||
SchoolDocumentPending<String> store = new SchoolDocumentPending<>(() -> 0);
|
||||
String first = store.put(1,"old"), other = store.put(2,"other"), latest = store.put(1,"latest");
|
||||
assertNull(store.get(first)); assertEquals("other",store.get(other)); assertEquals("latest",store.get(latest));
|
||||
assertEquals("latest",store.remove(latest)); assertNull(store.remove(latest));
|
||||
store.close();
|
||||
}
|
||||
@Test public void fourSlotLimitIsSharedAcrossRegistries() {
|
||||
SchoolDocumentPending<String> a = new SchoolDocumentPending<>(() -> 0), b = new SchoolDocumentPending<>(() -> 0);
|
||||
try {
|
||||
for(int i=0;i<4;i++) assertNotNull(a.put(i,"private"));
|
||||
assertNull(b.put(7,"over-cap"));
|
||||
a.close(); assertNotNull(b.put(7,"released"));
|
||||
} finally { a.close(); b.close(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tpschedule.app;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
public class SchoolDocumentReaderTest {
|
||||
private static final String URL = "https://eams.uestc.edu.cn/eams/home!main.action";
|
||||
// Mimics beangle3 head.ftl: presence of XRW suppresses the complete head.
|
||||
static class Backend extends HttpURLConnection {
|
||||
int status = 200;
|
||||
String mime = "text/html; charset=UTF-8";
|
||||
byte[] body;
|
||||
String encoding;
|
||||
List<String> cookies = new ArrayList<>();
|
||||
boolean disconnected;
|
||||
Backend() throws Exception { super(new URL(URL)); }
|
||||
public void connect() { }
|
||||
public boolean usingProxy() { return false; }
|
||||
public void disconnect() { disconnected = true; }
|
||||
public int getResponseCode() { return status; }
|
||||
public String getContentType() { return mime; }
|
||||
public String getContentEncoding() { return encoding; }
|
||||
public Map<String,List<String>> getHeaderFields() { return Collections.singletonMap("Set-Cookie", cookies); }
|
||||
public InputStream getInputStream() {
|
||||
if (body != null) return new ByteArrayInputStream(body);
|
||||
boolean fragment = getRequestProperty("X-Requested-With") != null;
|
||||
return new ByteArrayInputStream((fragment ? "<div>课表</div>" : "<html><head><script src='jquery.js'></script><link rel='stylesheet' href='site.css'></head><body>课表</body></html>").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
private SchoolDocumentReader reader(Backend backend) { return new SchoolDocumentReader(url -> backend); }
|
||||
private void reject(String code, Throwing action) throws Exception {
|
||||
try { action.run(); fail("expected " + code); }
|
||||
catch (SchoolDocumentReader.Failure failure) { assertEquals(code, failure.code); assertEquals(code, failure.getMessage()); }
|
||||
}
|
||||
interface Throwing { void run() throws Exception; }
|
||||
|
||||
@Test public void missingXrwRestoresFullHeadWithSameSessionGet() throws Exception {
|
||||
Backend backend = new Backend();
|
||||
SchoolDocumentReader.Document document = reader(backend).fetch(URL, "SESSION=private", "fixture-agent");
|
||||
assertTrue(document.html.contains("jquery.js"));
|
||||
assertTrue(document.html.contains("site.css"));
|
||||
assertTrue(document.html.contains("课表"));
|
||||
assertEquals("GET", backend.getRequestMethod());
|
||||
assertEquals("SESSION=private", backend.getRequestProperty("Cookie"));
|
||||
assertEquals("fixture-agent", backend.getRequestProperty("User-Agent"));
|
||||
assertNull(backend.getRequestProperty("X-Requested-With"));
|
||||
assertFalse(backend.getInstanceFollowRedirects());
|
||||
assertTrue(backend.disconnected);
|
||||
}
|
||||
@Test public void missingSessionMakesNoRequest() throws Exception {
|
||||
reject("no_session", () -> new SchoolDocumentReader(url -> { fail("request before session validation"); return null; }).fetch(URL, "", "ua"));
|
||||
}
|
||||
@Test public void rejectsUnsafeTargetsAndDecodedCredentialQuery() throws Exception {
|
||||
String[] urls = {"http://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn:444/eams/", "https://eams.uestc.edu.cn.evil/eams/", "https://user@eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/logout.action", "https://eams.uestc.edu.cn/eams/home!save.action", URL+"?%74icket=secret", URL+"?X-Requested-With=a", URL+"?action=delete", URL+"?opType=save", "https://eams.uestc.edu.cn/eams/%2e%2e/eams/"};
|
||||
for (String url : urls) reject("unsafe_url", () -> reader(new Backend()).fetch(url, "S=x", "ua"));
|
||||
}
|
||||
@Test public void allowsOnlySettledReadonlyRoutes() throws Exception {
|
||||
String[] urls = {"https://eams.uestc.edu.cn/eams", "https://eams.uestc.edu.cn/eams/", "https://eams.uestc.edu.cn/eams/homeExt!index.action", "https://eams.uestc.edu.cn/eams/courseTableForStd!courseTable.action?semester.id=42", "https://eams.uestc.edu.cn/eams/stdExamTable!examTable.action", "https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/42", "https://yjsjy.uestc.edu.cn/pyxx/pygl/kskcxx/index"};
|
||||
for (String url : urls) assertNotNull(reader(new Backend()).fetch(url, "S=x", "ua"));
|
||||
}
|
||||
@Test public void neverFollowsRedirectsIncludingSameHost() throws Exception {
|
||||
Backend backend = new Backend(); backend.status = 302;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
assertFalse(backend.getInstanceFollowRedirects()); assertTrue(backend.disconnected);
|
||||
}
|
||||
@Test public void rejectsNonHtmlOrNonSuccess() throws Exception {
|
||||
for (String mime : new String[]{"application/json", "text/plain", null}) {
|
||||
Backend backend = new Backend(); backend.mime = mime;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
for (int status : new int[]{201,204,401,403,500}) {
|
||||
Backend backend = new Backend(); backend.status = status;
|
||||
reject("invalid_response", () -> reader(backend).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
}
|
||||
@Test public void enforcesDecodedLimitEvenForGzip() throws Exception {
|
||||
byte[] oversized = new byte[2*1024*1024+1]; Arrays.fill(oversized, (byte)'a');
|
||||
Backend plain = new Backend(); plain.body = oversized;
|
||||
reject("too_large", () -> reader(plain).fetch(URL, "S=x", "ua"));
|
||||
Backend zipped = new Backend(); zipped.encoding = "gzip";
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gzip = new GZIPOutputStream(buffer)) { gzip.write(oversized); }
|
||||
zipped.body = buffer.toByteArray();
|
||||
reject("too_large", () -> reader(zipped).fetch(URL, "S=x", "ua"));
|
||||
}
|
||||
@Test public void acceptsHtmlCharsetAndGzip() throws Exception {
|
||||
Backend backend = new Backend(); backend.mime = "application/xhtml+xml; charset=GB18030";
|
||||
backend.body = "<html>中文</html>".getBytes("GB18030");
|
||||
assertEquals("<html>中文</html>", reader(backend).fetch(URL,"S=x","ua").html);
|
||||
}
|
||||
@Test public void boundsCompressedHeadersBeforeDecoderProducesOutput() throws Exception {
|
||||
Backend backend = new Backend(); backend.encoding = "gzip";
|
||||
backend.body = new byte[2*1024*1024+1]; Arrays.fill(backend.body,(byte)'a');
|
||||
byte[] gzipHeader = {(byte)0x1f,(byte)0x8b,8,8,0,0,0,0,0,3};
|
||||
System.arraycopy(gzipHeader,0,backend.body,0,gzipHeader.length);
|
||||
reject("too_large", () -> reader(backend).fetch(URL,"S=x","ua"));
|
||||
}
|
||||
@Test public void onlyKeepsBoundedSameSchoolCookieScope() throws Exception {
|
||||
Backend backend = new Backend();
|
||||
backend.cookies = Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/", "BAD=x; Domain=.uestc.edu.cn; Path=/eams", "BAD=x; Domain=eams.uestc.edu.cn.evil; Path=/eams", "BAD=x; Path=/", "BAD=x; Path=/eamsOther", "BAD=x\r\nInjected: yes", "BAD=x; Domain=eams.uestc.edu.cn; Domain=evil", "z="+String.join("", Collections.nCopies(4097,"a")));
|
||||
assertEquals(Arrays.asList("SID=next; Path=/eams; Secure; HttpOnly", "A=b; Domain=eams.uestc.edu.cn; Path=/eams/"),reader(backend).fetch(URL,"S=x","ua").setCookies);
|
||||
}
|
||||
@Test public void sanitizesTransportFailure() throws Exception {
|
||||
reject("network_failed", () -> new SchoolDocumentReader(url -> { throw new IOException("secret-cookie-and-url"); }).fetch(URL,"S=x","ua"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user