fix: recover authenticated undergraduate full pages with session guards

This commit is contained in:
Codex
2026-10-15 16:39:27 +08:00
parent 83b0c7e177
commit 79d268fd5e
22 changed files with 1418 additions and 13 deletions
+2
View File
@@ -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 {
@@ -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"));
}
}