From aefdb40994ea97219a9673d7ce888e916ea8a6b4 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 13:28:18 +0200 Subject: [PATCH 1/4] Send a captured cookie to its own host only The capture wrote field 1 of every Netscape record as TRUE, which is the include-subdomains flag. The engine reads it: cookie_is_host_only takes only an explicit FALSE, and the match in cookie_findf is domain_match && (!host_only || same_domain). So a session captured for one host was sent to every subdomain the crawl reached, and saved back with a leading dot. Writing FALSE alone would lose cookies instead. A host-only record reaches one host, and the capture only knew the hosts the user browsed, so a crawl starting on a host the login never opened would have gone out without a session. CookieManager answers for a host the browser never opened, so the login now also captures the hosts the crawl starts from, read off the address field. Forget cleared the whole store, which signed the user out of every other project, because the cookie store is per app and setDataDirectorySuffix is per process. It now expires the cookies our own hosts answer with, from each host and its parent domains, and falls back to the whole store only when one of those hosts still answers. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../httrack/android/BrowserLoginActivity.java | 48 +++++++++++++- .../httrack/android/BrowserLoginCookies.java | 5 +- .../httrack/android/BrowserLoginPolicy.java | 65 +++++++++++++++++++ .../com/httrack/android/HTTrackActivity.java | 2 + .../android/BrowserLoginCookiesTest.java | 8 ++- .../android/BrowserLoginPolicyTest.java | 44 +++++++++++++ .../android/BrowserLoginWiringTest.java | 30 +++++++++ 7 files changed, 196 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java index bfb26c4..8b6f5d9 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java @@ -67,6 +67,9 @@ public class BrowserLoginActivity extends FragmentActivity { /** Where to write the cookies.txt the engine will read. */ static final String EXTRA_COOKIES_FILE = "com.httrack.android.cookiesFile"; + /** The hosts the crawl starts from, which the capture covers besides the ones visited. */ + static final String EXTRA_CRAWL_HOSTS = "com.httrack.android.crawlHosts"; + /** Results: the browser's own User-Agent, and what the file ended up holding. */ static final String EXTRA_USER_AGENT = "com.httrack.android.userAgent"; static final String EXTRA_COOKIE_COUNT = "com.httrack.android.cookieCount"; @@ -92,6 +95,11 @@ protected void onCreate(final Bundle savedInstanceState) { final Bundle extras = getIntent().getExtras(); final String url = extras != null ? extras.getString(EXTRA_URL) : null; cookiesFile = extras != null ? File.class.cast(extras.get(EXTRA_COOKIES_FILE)) : null; + final List crawlHosts = + extras != null ? extras.getStringArrayList(EXTRA_CRAWL_HOSTS) : null; + if (crawlHosts != null) { + hosts.addAll(crawlHosts); + } if (url == null || cookiesFile == null) { Log.w(getClass().getSimpleName(), "no address to log in to"); finish(); @@ -213,8 +221,18 @@ private void forget() { if (cookiesFile == null) { return; } - CookieManager.getInstance().removeAllCookies(null); - CookieManager.getInstance().flush(); + final CookieManager cookies = CookieManager.getInstance(); + for (final String host : hosts) { + expire(cookies, host); + } + cookies.flush(); + /* There is no per-site removal, so the loop above is best effort: a cookie on a path other + than "/" survives it. A Forget that leaves the user signed in is worse than one that + reaches too far, so fall back to the whole store when any of our hosts still answers. */ + if (anySessionLeft(cookies)) { + cookies.removeAllCookies(null); + cookies.flush(); + } hosts.clear(); if (cookiesFile.exists() && !cookiesFile.delete()) { Log.w(getClass().getSimpleName(), "could not delete " + cookiesFile); @@ -226,6 +244,32 @@ private void forget() { Toast.makeText(this, getString(R.string.browser_login_forgotten), Toast.LENGTH_SHORT).show(); } + /** Drop every cookie HOST answers with, from the host itself and from each parent domain. */ + private static void expire(final CookieManager cookies, final String host) { + final String url = BrowserLoginPolicy.SCHEME + "://" + host + "/"; + final String header = cookies.getCookie(url); + if (header == null) { + return; + } + for (final String[] cookie : BrowserLoginCookies.split(header)) { + cookies.setCookie(url, BrowserLoginPolicy.expiredCookie(cookie[0], null)); + for (final String parent : BrowserLoginPolicy.parentDomains(host)) { + cookies.setCookie(url, BrowserLoginPolicy.expiredCookie(cookie[0], parent)); + } + } + } + + /** Whether any host this login knows about still hands back a cookie. */ + private boolean anySessionLeft(final CookieManager cookies) { + for (final String host : hosts) { + final String header = cookies.getCookie(BrowserLoginPolicy.SCHEME + "://" + host + "/"); + if (header != null && header.length() != 0) { + return true; + } + } + return false; + } + /** "Cancel" */ public void onClickLoginCancel(final View view) { setResult(RESULT_CANCELED); diff --git a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java index f52b0ed..c0956cf 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java @@ -116,7 +116,10 @@ static String line(final String host, final String name, final String value) { } /* No length check on the whole line: the three caps above bound it well under the line_max at which cookie_load drops a line, which BrowserLoginEngineLimitsTest proves. */ - return host + "\tTRUE\t/\tFALSE\t0\t" + name + "\t" + value + "\n"; + /* Field 1 FALSE is host-only, which cookie_is_host_only reads: the browser told us what + it sends to this host, so widening it to every subdomain sends a session we never saw + there. Field 4 zero is a session cookie, which cookie_sendable never treats as expired. */ + return host + "\tFALSE\t/\tFALSE\t0\t" + name + "\t" + value + "\n"; } /** diff --git a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java index ecd65f8..3090890 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java @@ -2,7 +2,11 @@ import java.net.URI; import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; /** * What the browser login opens, and what it changes about the crawl. A logged-in crawl has to @@ -78,6 +82,67 @@ static String hostOf(final String url) { return host.toLowerCase(Locale.ROOT); } + /** + * Every host the crawl starts from, which the capture needs besides the hosts the user + * visited. A host-only cookie reaches that host alone, and CookieManager answers for a host + * the browser never opened, so asking for these costs nothing and no visit. + * + * @param urlField + * the field's text, one or more whitespace-separated addresses + * @return the hosts, empty when the field names none + */ + static Set crawlHosts(final String urlField) { + final Set out = new LinkedHashSet(); + for (final String token : CommandlineTokens.urlTokens(urlField)) { + final String host = hostOf(secureUrl(token)); + if (host != null) { + out.add(host); + } + } + return out; + } + + /** + * The Set-Cookie value that drops NAME from DOMAIN, or from the host itself when DOMAIN is + * null. CookieManager has no per-site removal, so a cookie is dropped by overwriting it with + * one already past its expiry. + * + * @param name + * the cookie's name + * @param domain + * the domain to drop it from, or null for a host-only cookie + * @return the value to hand setCookie + */ + static String expiredCookie(final String name, final String domain) { + final String header = name + "=; Max-Age=0; Path=/"; + return domain == null ? header : header + "; Domain=" + domain; + } + + /** + * The parent domains of HOST, longest first, stopping before the last two labels. A session + * cookie is often set on a parent, and dropping it from the host alone would leave it live. + * + * @param host + * a lowercased host name + * @return the parents, empty for a host with no more than two labels + */ + static List parentDomains(final String host) { + final List out = new ArrayList(); + if (host == null) { + return out; + } + /* A public suffix needs no special case: the browser refuses a cookie on one, so an + attempt at "co.uk" is turned down rather than widening anything. */ + for (int cut = host.indexOf('.'); cut != -1; cut = host.indexOf('.', cut + 1)) { + final String parent = host.substring(cut + 1); + if (parent.indexOf('.') == -1) { + break; + } + out.add(parent); + } + return out; + } + /** * The connection count to use, which only ever goes down. * diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index 8fbc999..ad5aaa8 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -2915,6 +2915,8 @@ public void onClickBrowserLogin(final View view) { final Intent intent = new Intent(this, BrowserLoginActivity.class); intent.putExtra(BrowserLoginActivity.EXTRA_URL, url); intent.putExtra(BrowserLoginActivity.EXTRA_COOKIES_FILE, cookies); + intent.putStringArrayListExtra(BrowserLoginActivity.EXTRA_CRAWL_HOSTS, + new ArrayList(BrowserLoginPolicy.crawlHosts(getMap(R.id.fieldWebsiteURLs)))); startActivityForResult(intent, ACTIVITY_BROWSER_LOGIN); } diff --git a/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java index 3decd7c..4d447dd 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java @@ -44,7 +44,9 @@ public void writesTheSevenFieldsTheEngineReads() { final String[] fields = lines.get(0).split("\t", -1); assertEquals(7, fields.length); assertEquals("www.example.com", fields[0]); - assertEquals("TRUE", fields[1]); + /* Host-only. The browser told us what it sends to this host, so TRUE here would hand the + session to every subdomain the crawl reaches, which cookie_is_host_only would allow. */ + assertEquals("FALSE", fields[1]); assertEquals("/", fields[2]); assertEquals("FALSE", fields[3]); assertEquals("0", fields[4]); @@ -182,8 +184,8 @@ public void aNameOnTwoHostsSurvivesOnBoth() { final BrowserLoginCookies.Result result = BrowserLoginCookies.file(byHost); assertEquals(2, result.cookies); assertEquals(2, result.hosts); - assertTrue(result.text, result.text.contains("a.example\tTRUE\t/\tFALSE\t0\tsid\tone\n")); - assertTrue(result.text, result.text.contains("b.example\tTRUE\t/\tFALSE\t0\tsid\ttwo\n")); + assertTrue(result.text, result.text.contains("a.example\tFALSE\t/\tFALSE\t0\tsid\tone\n")); + assertTrue(result.text, result.text.contains("b.example\tFALSE\t/\tFALSE\t0\tsid\ttwo\n")); } @Test diff --git a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java index 46f8d84..8e687c2 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java @@ -4,6 +4,9 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import java.util.Arrays; +import java.util.Collections; + import org.junit.Test; /** Which address the login window opens, and what the hand-off changes about the crawl. */ @@ -107,4 +110,45 @@ public void anExistingLogoutRuleIsLeftAsTheUserWroteIt() { // Only the word itself counts, so a rule about another name still gets the filter. assertEquals("-*signout* -*logout*", BrowserLoginPolicy.rules("-*signout*")); } + + /** Host-only cookies reach one host each, so the capture has to cover every start host. */ + @Test + public void everyAddressTheCrawlStartsFromIsAHostToCapture() { + assertEquals(Arrays.asList("a.example", "b.example"), + list(BrowserLoginPolicy.crawlHosts("https://a.example/x http://b.example/"))); + // A bare host and a duplicate, because the field is free text the user typed. + assertEquals(Collections.singletonList("a.example"), + list(BrowserLoginPolicy.crawlHosts("a.example https://a.example/deep"))); + // Lowercased, because the engine folds a cookie domain before comparing it. + assertEquals(Collections.singletonList("a.example"), + list(BrowserLoginPolicy.crawlHosts("https://A.Example/"))); + assertTrue(BrowserLoginPolicy.crawlHosts("").isEmpty()); + assertTrue(BrowserLoginPolicy.crawlHosts(null).isEmpty()); + // urlTokens drops an option, so a tampered profile cannot add a host through this field. + assertTrue(BrowserLoginPolicy.crawlHosts("-O1 /tmp/x").isEmpty()); + } + + @Test + public void anExpiredCookieNamesItsDomainOnlyWhenItHasOne() { + assertEquals("sid=; Max-Age=0; Path=/", BrowserLoginPolicy.expiredCookie("sid", null)); + assertEquals("sid=; Max-Age=0; Path=/; Domain=example.com", + BrowserLoginPolicy.expiredCookie("sid", "example.com")); + } + + /** A session cookie is often set on a parent, so dropping it from the host alone leaves it. */ + @Test + public void theParentsStopBeforeTheLastTwoLabels() { + assertEquals(Arrays.asList("b.example.com", "example.com"), + BrowserLoginPolicy.parentDomains("a.b.example.com")); + assertEquals(Collections.singletonList("example.com"), + BrowserLoginPolicy.parentDomains("www.example.com")); + // Two labels are already the shortest a cookie may name, so there is no parent to try. + assertTrue(BrowserLoginPolicy.parentDomains("example.com").isEmpty()); + assertTrue(BrowserLoginPolicy.parentDomains("localhost").isEmpty()); + assertTrue(BrowserLoginPolicy.parentDomains(null).isEmpty()); + } + + private static java.util.List list(final java.util.Set hosts) { + return new java.util.ArrayList(hosts); + } } diff --git a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java index 4894866..47b7556 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java @@ -156,6 +156,36 @@ public void theStagedWriteBuildsItsNameFromTheSharedHelper() throws IOException assertTrue(body, body.contains("staged.renameTo(file)")); } + /** Forget reaches the whole store only after the narrow pass leaves a session behind. */ + @Test + public void forgetClearsOurHostsBeforeItClearsEverything() throws IOException { + final String source = TestSources + .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity")); + final String body = TestSources.balancedBlock(source, + TestSources.indexOf(source, "private void forget()")); + assertTrue(body, body.contains("expire(cookies, host)")); + // Ordered, because a wide clear ahead of the check would make the narrow pass dead code. + final int checked = body.indexOf("anySessionLeft(cookies)"); + final int wide = body.indexOf("removeAllCookies"); + assertTrue(body, checked != -1 && wide != -1 && checked < wide); + } + + /** A host-only cookie reaches one host, so the capture covers the crawl's own start hosts. */ + @Test + public void theLoginIsToldWhichHostsTheCrawlStartsFrom() throws IOException { + final String caller = TestSources + .withoutCommentsAndStrings(TestSources.javaSource("HTTrackActivity")); + final String open = TestSources.balancedBlock(caller, + TestSources.indexOf(caller, "public void onClickBrowserLogin(")); + assertTrue(open, open.contains("BrowserLoginPolicy.crawlHosts(")); + assertTrue(open, open.contains("EXTRA_CRAWL_HOSTS")); + final String source = TestSources + .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity")); + final String created = TestSources.balancedBlock(source, + TestSources.indexOf(source, "protected void onCreate(")); + assertTrue(created, created.contains("hosts.addAll(crawlHosts)")); + } + /** -%K is read only when the engine accepts cookies, so the hand-off has to tick the box. */ @Test public void theHandOverTurnsCookiesOn() throws IOException { From 5e66c485d3efdc7bdbf60312ccd4190c221e1bfc Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 13:40:02 +0200 Subject: [PATCH 2/4] Shorten two comments the review flagged The field-4 note explained a byte this change does not touch, and the test already pins it. The other two findings were a colon carrying the logic and a sentence over the length limit. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../main/java/com/httrack/android/BrowserLoginCookies.java | 6 +++--- .../main/java/com/httrack/android/BrowserLoginPolicy.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java index c0956cf..f6c4f6b 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java @@ -116,9 +116,9 @@ static String line(final String host, final String name, final String value) { } /* No length check on the whole line: the three caps above bound it well under the line_max at which cookie_load drops a line, which BrowserLoginEngineLimitsTest proves. */ - /* Field 1 FALSE is host-only, which cookie_is_host_only reads: the browser told us what - it sends to this host, so widening it to every subdomain sends a session we never saw - there. Field 4 zero is a session cookie, which cookie_sendable never treats as expired. */ + /* Field 1 FALSE is host-only, which cookie_is_host_only reads. The browser told us what + it sends to this host, so widening it to every subdomain would send a session we never + saw there. */ return host + "\tFALSE\t/\tFALSE\t0\t" + name + "\t" + value + "\n"; } diff --git a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java index 3090890..5338c3a 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java @@ -131,8 +131,8 @@ static List parentDomains(final String host) { if (host == null) { return out; } - /* A public suffix needs no special case: the browser refuses a cookie on one, so an - attempt at "co.uk" is turned down rather than widening anything. */ + /* A public suffix needs no special case, because the browser refuses a cookie on one, so + an attempt at "co.uk" is turned down rather than widening anything. */ for (int cut = host.indexOf('.'); cut != -1; cut = host.indexOf('.', cut + 1)) { final String parent = host.substring(cut + 1); if (parent.indexOf('.') == -1) { From a218b37a6e76e94b540c0bc5230dfd5a048db9d2 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 13:47:45 +0200 Subject: [PATCH 3/4] Narrow this PR to the Forget fix, and fix what the review found Two review agents measured the cookie-scope half against the vendored engine and it does not hold. Writing field 1 as FALSE is a no-op for any captured host with three or more labels, because the engine's own match already needed the exact host. Where it does change something, it loses cookies: a start host added to the address field after the login goes out logged-out where master sends the cookie. The capture cannot tell a host-only cookie from one carrying Domain, so master guesses domain-wide and that version guessed host-only. Both are guesses. That half goes to its own PR with a design that recovers the real scope. So this PR keeps the Forget fix alone, and the crawl-host seed goes with the half it was protecting. Dropping the seed also drops its own bug: a Forget cleared the host set, and nothing re-seeded it, so the next capture covered fewer hosts than the first. Three fixes the review found in the clear itself. Every value now carries Secure, because Chromium refuses to set a __Host- or __Secure- cookie without it and those prefixes carry the session on most login sites. The host's own Domain form is tried, which a two-label host had no other way to reach. And the residual check asks only about the names it tried, so an unrelated consent cookie no longer reads as a live session and sends the clear on to every other site. The rules moved into BrowserLoginPolicy, where they are pure and tested directly. The source-text guard could not see a negated condition or a neutered callee, which the review demonstrated with four mutants. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../httrack/android/BrowserLoginActivity.java | 52 +++++---------- .../httrack/android/BrowserLoginCookies.java | 5 +- .../httrack/android/BrowserLoginPolicy.java | 65 +++++++++++------- .../com/httrack/android/HTTrackActivity.java | 2 - .../android/BrowserLoginCookiesTest.java | 8 +-- .../android/BrowserLoginPolicyTest.java | 66 +++++++++++++------ .../android/BrowserLoginWiringTest.java | 27 ++------ 7 files changed, 115 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java index 8b6f5d9..8c64cd4 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java @@ -67,9 +67,6 @@ public class BrowserLoginActivity extends FragmentActivity { /** Where to write the cookies.txt the engine will read. */ static final String EXTRA_COOKIES_FILE = "com.httrack.android.cookiesFile"; - /** The hosts the crawl starts from, which the capture covers besides the ones visited. */ - static final String EXTRA_CRAWL_HOSTS = "com.httrack.android.crawlHosts"; - /** Results: the browser's own User-Agent, and what the file ended up holding. */ static final String EXTRA_USER_AGENT = "com.httrack.android.userAgent"; static final String EXTRA_COOKIE_COUNT = "com.httrack.android.cookieCount"; @@ -95,11 +92,6 @@ protected void onCreate(final Bundle savedInstanceState) { final Bundle extras = getIntent().getExtras(); final String url = extras != null ? extras.getString(EXTRA_URL) : null; cookiesFile = extras != null ? File.class.cast(extras.get(EXTRA_COOKIES_FILE)) : null; - final List crawlHosts = - extras != null ? extras.getStringArrayList(EXTRA_CRAWL_HOSTS) : null; - if (crawlHosts != null) { - hosts.addAll(crawlHosts); - } if (url == null || cookiesFile == null) { Log.w(getClass().getSimpleName(), "no address to log in to"); finish(); @@ -222,17 +214,19 @@ private void forget() { return; } final CookieManager cookies = CookieManager.getInstance(); + boolean signedIn = false; for (final String host : hosts) { - expire(cookies, host); + final Set tried = expire(cookies, host); + final String left = cookies.getCookie(BrowserLoginPolicy.SCHEME + "://" + host + "/"); + signedIn = signedIn || BrowserLoginPolicy.stillSignedIn(left, tried); } - cookies.flush(); - /* There is no per-site removal, so the loop above is best effort: a cookie on a path other - than "/" survives it. A Forget that leaves the user signed in is worse than one that - reaches too far, so fall back to the whole store when any of our hosts still answers. */ - if (anySessionLeft(cookies)) { + /* The pass above is best effort, because a cookie on a path other than "/" survives it. A + Forget that leaves the user signed in is worse than one that reaches too far, so clear + the whole store when a name we tried to drop is still there. */ + if (signedIn) { cookies.removeAllCookies(null); - cookies.flush(); } + cookies.flush(); hosts.clear(); if (cookiesFile.exists() && !cookiesFile.delete()) { Log.w(getClass().getSimpleName(), "could not delete " + cookiesFile); @@ -244,30 +238,18 @@ private void forget() { Toast.makeText(this, getString(R.string.browser_login_forgotten), Toast.LENGTH_SHORT).show(); } - /** Drop every cookie HOST answers with, from the host itself and from each parent domain. */ - private static void expire(final CookieManager cookies, final String host) { + /** Expire every cookie HOST answers with, and return the names that were tried. */ + private static Set expire(final CookieManager cookies, final String host) { final String url = BrowserLoginPolicy.SCHEME + "://" + host + "/"; final String header = cookies.getCookie(url); - if (header == null) { - return; - } - for (final String[] cookie : BrowserLoginCookies.split(header)) { - cookies.setCookie(url, BrowserLoginPolicy.expiredCookie(cookie[0], null)); - for (final String parent : BrowserLoginPolicy.parentDomains(host)) { - cookies.setCookie(url, BrowserLoginPolicy.expiredCookie(cookie[0], parent)); - } + final Set tried = new LinkedHashSet(); + for (final String[] cookie : BrowserLoginCookies.split(header == null ? "" : header)) { + tried.add(cookie[0]); } - } - - /** Whether any host this login knows about still hands back a cookie. */ - private boolean anySessionLeft(final CookieManager cookies) { - for (final String host : hosts) { - final String header = cookies.getCookie(BrowserLoginPolicy.SCHEME + "://" + host + "/"); - if (header != null && header.length() != 0) { - return true; - } + for (final String value : BrowserLoginPolicy.expiryHeaders(header, host)) { + cookies.setCookie(url, value); } - return false; + return tried; } /** "Cancel" */ diff --git a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java index f6c4f6b..f52b0ed 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginCookies.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginCookies.java @@ -116,10 +116,7 @@ static String line(final String host, final String name, final String value) { } /* No length check on the whole line: the three caps above bound it well under the line_max at which cookie_load drops a line, which BrowserLoginEngineLimitsTest proves. */ - /* Field 1 FALSE is host-only, which cookie_is_host_only reads. The browser told us what - it sends to this host, so widening it to every subdomain would send a session we never - saw there. */ - return host + "\tFALSE\t/\tFALSE\t0\t" + name + "\t" + value + "\n"; + return host + "\tTRUE\t/\tFALSE\t0\t" + name + "\t" + value + "\n"; } /** diff --git a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java index 5338c3a..bf73847 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java @@ -83,39 +83,58 @@ static String hostOf(final String url) { } /** - * Every host the crawl starts from, which the capture needs besides the hosts the user - * visited. A host-only cookie reaches that host alone, and CookieManager answers for a host - * the browser never opened, so asking for these costs nothing and no visit. + * The values to hand {@code setCookie} to drop every cookie in COOKIEHEADER from HOST. + * CookieManager has no per-site removal, so a cookie goes by overwriting it with one already + * past its expiry. Chromium keys a cookie on its name, domain and path, so each name needs + * one value per domain it may have been set on: the host itself, the host as a domain + * cookie, then each parent. * - * @param urlField - * the field's text, one or more whitespace-separated addresses - * @return the hosts, empty when the field names none + * @param cookieHeader + * what {@code getCookie} answered for HOST, possibly null + * @param host + * the host those cookies came from + * @return the values, empty when there is nothing to drop */ - static Set crawlHosts(final String urlField) { - final Set out = new LinkedHashSet(); - for (final String token : CommandlineTokens.urlTokens(urlField)) { - final String host = hostOf(secureUrl(token)); - if (host != null) { - out.add(host); + static List expiryHeaders(final String cookieHeader, final String host) { + final List out = new ArrayList(); + if (cookieHeader == null || host == null) { + return out; + } + for (final String[] cookie : BrowserLoginCookies.split(cookieHeader)) { + /* Secure on every one, because Chromium refuses to set a __Host- or __Secure- cookie + without it, and those prefixes carry the session on most login sites. It is not part + of the key, so the value still drops a cookie that was not secure. */ + final String header = cookie[0] + "=; Max-Age=0; Path=/; Secure"; + out.add(header); + out.add(header + "; Domain=" + host); + for (final String parent : parentDomains(host)) { + out.add(header + "; Domain=" + parent); } } return out; } /** - * The Set-Cookie value that drops NAME from DOMAIN, or from the host itself when DOMAIN is - * null. CookieManager has no per-site removal, so a cookie is dropped by overwriting it with - * one already past its expiry. + * Whether any cookie this login tried to drop is still there. Asking only about those names + * keeps an unrelated consent or language cookie from reading as a live session, which would + * send the caller on to clear every other site. * - * @param name - * the cookie's name - * @param domain - * the domain to drop it from, or null for a host-only cookie - * @return the value to hand setCookie + * @param cookieHeader + * what {@code getCookie} answers now, possibly null + * @param dropped + * the names that were expired + * @return true when one of them survived */ - static String expiredCookie(final String name, final String domain) { - final String header = name + "=; Max-Age=0; Path=/"; - return domain == null ? header : header + "; Domain=" + domain; + static boolean stillSignedIn(final String cookieHeader, final Set dropped) { + if (cookieHeader == null) { + return false; + } + for (final String[] cookie : BrowserLoginCookies.split(cookieHeader)) { + if (dropped.contains(cookie[0])) { + return true; + } + } + return false; } /** diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index ad5aaa8..8fbc999 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -2915,8 +2915,6 @@ public void onClickBrowserLogin(final View view) { final Intent intent = new Intent(this, BrowserLoginActivity.class); intent.putExtra(BrowserLoginActivity.EXTRA_URL, url); intent.putExtra(BrowserLoginActivity.EXTRA_COOKIES_FILE, cookies); - intent.putStringArrayListExtra(BrowserLoginActivity.EXTRA_CRAWL_HOSTS, - new ArrayList(BrowserLoginPolicy.crawlHosts(getMap(R.id.fieldWebsiteURLs)))); startActivityForResult(intent, ACTIVITY_BROWSER_LOGIN); } diff --git a/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java index 4d447dd..3decd7c 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginCookiesTest.java @@ -44,9 +44,7 @@ public void writesTheSevenFieldsTheEngineReads() { final String[] fields = lines.get(0).split("\t", -1); assertEquals(7, fields.length); assertEquals("www.example.com", fields[0]); - /* Host-only. The browser told us what it sends to this host, so TRUE here would hand the - session to every subdomain the crawl reaches, which cookie_is_host_only would allow. */ - assertEquals("FALSE", fields[1]); + assertEquals("TRUE", fields[1]); assertEquals("/", fields[2]); assertEquals("FALSE", fields[3]); assertEquals("0", fields[4]); @@ -184,8 +182,8 @@ public void aNameOnTwoHostsSurvivesOnBoth() { final BrowserLoginCookies.Result result = BrowserLoginCookies.file(byHost); assertEquals(2, result.cookies); assertEquals(2, result.hosts); - assertTrue(result.text, result.text.contains("a.example\tFALSE\t/\tFALSE\t0\tsid\tone\n")); - assertTrue(result.text, result.text.contains("b.example\tFALSE\t/\tFALSE\t0\tsid\ttwo\n")); + assertTrue(result.text, result.text.contains("a.example\tTRUE\t/\tFALSE\t0\tsid\tone\n")); + assertTrue(result.text, result.text.contains("b.example\tTRUE\t/\tFALSE\t0\tsid\ttwo\n")); } @Test diff --git a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java index 8e687c2..82c3c34 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java @@ -1,11 +1,15 @@ package com.httrack.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import org.junit.Test; @@ -111,28 +115,51 @@ public void anExistingLogoutRuleIsLeftAsTheUserWroteIt() { assertEquals("-*signout* -*logout*", BrowserLoginPolicy.rules("-*signout*")); } - /** Host-only cookies reach one host each, so the capture has to cover every start host. */ + /** Chromium keys a cookie on name, domain and path, so each domain it may hold needs one. */ @Test - public void everyAddressTheCrawlStartsFromIsAHostToCapture() { - assertEquals(Arrays.asList("a.example", "b.example"), - list(BrowserLoginPolicy.crawlHosts("https://a.example/x http://b.example/"))); - // A bare host and a duplicate, because the field is free text the user typed. - assertEquals(Collections.singletonList("a.example"), - list(BrowserLoginPolicy.crawlHosts("a.example https://a.example/deep"))); - // Lowercased, because the engine folds a cookie domain before comparing it. - assertEquals(Collections.singletonList("a.example"), - list(BrowserLoginPolicy.crawlHosts("https://A.Example/"))); - assertTrue(BrowserLoginPolicy.crawlHosts("").isEmpty()); - assertTrue(BrowserLoginPolicy.crawlHosts(null).isEmpty()); - // urlTokens drops an option, so a tampered profile cannot add a host through this field. - assertTrue(BrowserLoginPolicy.crawlHosts("-O1 /tmp/x").isEmpty()); + public void everyDomainTheCookieCouldSitOnIsExpired() { + final List out = BrowserLoginPolicy.expiryHeaders("sid=abc", "www.a.example.com"); + assertEquals(Arrays.asList("sid=; Max-Age=0; Path=/; Secure", + "sid=; Max-Age=0; Path=/; Secure; Domain=www.a.example.com", + "sid=; Max-Age=0; Path=/; Secure; Domain=a.example.com", + "sid=; Max-Age=0; Path=/; Secure; Domain=example.com"), out); } + /** A two-label host has no parent, so its own domain form is the only one that reaches it. */ @Test - public void anExpiredCookieNamesItsDomainOnlyWhenItHasOne() { - assertEquals("sid=; Max-Age=0; Path=/", BrowserLoginPolicy.expiredCookie("sid", null)); - assertEquals("sid=; Max-Age=0; Path=/; Domain=example.com", - BrowserLoginPolicy.expiredCookie("sid", "example.com")); + public void theHostsOwnDomainFormIsAlwaysTried() { + assertEquals(Arrays.asList("sid=; Max-Age=0; Path=/; Secure", + "sid=; Max-Age=0; Path=/; Secure; Domain=a.example"), + BrowserLoginPolicy.expiryHeaders("sid=abc", "a.example")); + } + + /** Chromium refuses to set a __Host- or __Secure- cookie without Secure, and those carry + the session on most login sites. */ + @Test + public void everyValueCarriesSecure() { + for (final String value : BrowserLoginPolicy.expiryHeaders("__Host-sid=a; __Secure-t=b", + "a.example")) { + assertTrue(value, value.contains("; Secure")); + } + } + + @Test + public void nothingToExpireWhenTheHostAnswersWithNothing() { + assertTrue(BrowserLoginPolicy.expiryHeaders(null, "a.example").isEmpty()); + assertTrue(BrowserLoginPolicy.expiryHeaders("", "a.example").isEmpty()); + assertTrue(BrowserLoginPolicy.expiryHeaders("sid=abc", null).isEmpty()); + } + + /** Only the names we tried count, or an unrelated consent cookie clears every other site. */ + @Test + public void aSurvivingNameWeTriedMeansStillSignedIn() { + final Set tried = new LinkedHashSet(Arrays.asList("sid", "csrf")); + assertTrue(BrowserLoginPolicy.stillSignedIn("sid=abc", tried)); + assertTrue(BrowserLoginPolicy.stillSignedIn("lang=fr; csrf=x", tried)); + assertFalse(BrowserLoginPolicy.stillSignedIn("lang=fr; consent=1", tried)); + assertFalse(BrowserLoginPolicy.stillSignedIn("", tried)); + assertFalse(BrowserLoginPolicy.stillSignedIn(null, tried)); + assertFalse(BrowserLoginPolicy.stillSignedIn("sid=abc", new LinkedHashSet())); } /** A session cookie is often set on a parent, so dropping it from the host alone leaves it. */ @@ -148,7 +175,4 @@ public void theParentsStopBeforeTheLastTwoLabels() { assertTrue(BrowserLoginPolicy.parentDomains(null).isEmpty()); } - private static java.util.List list(final java.util.Set hosts) { - return new java.util.ArrayList(hosts); - } } diff --git a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java index 47b7556..a886945 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java @@ -156,34 +156,21 @@ public void theStagedWriteBuildsItsNameFromTheSharedHelper() throws IOException assertTrue(body, body.contains("staged.renameTo(file)")); } - /** Forget reaches the whole store only after the narrow pass leaves a session behind. */ + /** The clear has to run off the shared helpers, which are where its rules are tested. */ @Test - public void forgetClearsOurHostsBeforeItClearsEverything() throws IOException { + public void forgetExpiresOurOwnHostsBeforeReachingTheStore() throws IOException { final String source = TestSources .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity")); final String body = TestSources.balancedBlock(source, TestSources.indexOf(source, "private void forget()")); - assertTrue(body, body.contains("expire(cookies, host)")); + assertTrue(body, body.contains("BrowserLoginPolicy.stillSignedIn(")); // Ordered, because a wide clear ahead of the check would make the narrow pass dead code. - final int checked = body.indexOf("anySessionLeft(cookies)"); + final int checked = body.indexOf("stillSignedIn"); final int wide = body.indexOf("removeAllCookies"); assertTrue(body, checked != -1 && wide != -1 && checked < wide); - } - - /** A host-only cookie reaches one host, so the capture covers the crawl's own start hosts. */ - @Test - public void theLoginIsToldWhichHostsTheCrawlStartsFrom() throws IOException { - final String caller = TestSources - .withoutCommentsAndStrings(TestSources.javaSource("HTTrackActivity")); - final String open = TestSources.balancedBlock(caller, - TestSources.indexOf(caller, "public void onClickBrowserLogin(")); - assertTrue(open, open.contains("BrowserLoginPolicy.crawlHosts(")); - assertTrue(open, open.contains("EXTRA_CRAWL_HOSTS")); - final String source = TestSources - .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity")); - final String created = TestSources.balancedBlock(source, - TestSources.indexOf(source, "protected void onCreate(")); - assertTrue(created, created.contains("hosts.addAll(crawlHosts)")); + final String pass = TestSources.balancedBlock(source, + TestSources.indexOf(source, "private static Set expire(")); + assertTrue(pass, pass.contains("BrowserLoginPolicy.expiryHeaders(header, host)")); } /** -%K is read only when the engine accepts cookies, so the hand-off has to tick the box. */ From 549c566a1735f68b6659c191bd93cf9634233c5d Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 21 Oct 2026 13:58:47 +0200 Subject: [PATCH 4/4] Keep the wide clear, and say what it does The second review round killed the narrow clear. Its fallback could not fire for the case its own comment named: getCookie answers for one path, so a cookie on a deeper path never appears in the header, never enters the set of names tried, and never shows up in the re-read. The stated trigger and the stated reason were disjoint. Worse, it forgot less than the code it replaced. A path-scoped session cookie, a nameless cookie, and a session held by an iframe sign-on on a host onPageStarted never reported all survived it, where the old unconditional clear took them. The user presses this to change accounts, so leaving them signed in is the bad direction. Android offers no per-site removal and nothing that enumerates what a narrower pass missed, so there is no safe way to narrow this. The clear stays wide and the message now says so. Splitting the store needs a per-project WebView data directory, which means a separate process, and that is a design rather than a fix. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../httrack/android/BrowserLoginActivity.java | 43 ++++------ .../httrack/android/BrowserLoginPolicy.java | 84 ------------------- app/src/main/res/values/strings.xml | 2 +- .../android/BrowserLoginPolicyTest.java | 68 --------------- .../android/BrowserLoginWiringTest.java | 17 ---- 5 files changed, 15 insertions(+), 199 deletions(-) diff --git a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java index 8c64cd4..8b9e44c 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginActivity.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginActivity.java @@ -208,25 +208,24 @@ public boolean onOptionsItemSelected(final MenuItem item) { return super.onOptionsItemSelected(item); } - /** Drop the saved session: the browser's cookies and the file the engine would have read. */ + /** + * Drop the saved session: the browser's cookies and the file the engine would have read. + * + * This signs the user out of every site in this app's browser, not just this project's. That + * is deliberate. CookieManager has no per-site removal, and nothing enumerates what a + * narrower pass would miss: getCookie answers for one path, so a cookie on a deeper path is + * invisible to it, a nameless cookie has no name to overwrite, and a session held by an + * iframe sign-on sits on a host onPageStarted never reported. A narrower Forget was measured + * and it left all three signed in, which is worse than reaching too far, because the user + * presses this to change accounts. The store is per app, so splitting it needs a per-project + * WebView data directory, which means a separate process. + */ private void forget() { if (cookiesFile == null) { return; } - final CookieManager cookies = CookieManager.getInstance(); - boolean signedIn = false; - for (final String host : hosts) { - final Set tried = expire(cookies, host); - final String left = cookies.getCookie(BrowserLoginPolicy.SCHEME + "://" + host + "/"); - signedIn = signedIn || BrowserLoginPolicy.stillSignedIn(left, tried); - } - /* The pass above is best effort, because a cookie on a path other than "/" survives it. A - Forget that leaves the user signed in is worse than one that reaches too far, so clear - the whole store when a name we tried to drop is still there. */ - if (signedIn) { - cookies.removeAllCookies(null); - } - cookies.flush(); + CookieManager.getInstance().removeAllCookies(null); + CookieManager.getInstance().flush(); hosts.clear(); if (cookiesFile.exists() && !cookiesFile.delete()) { Log.w(getClass().getSimpleName(), "could not delete " + cookiesFile); @@ -238,20 +237,6 @@ private void forget() { Toast.makeText(this, getString(R.string.browser_login_forgotten), Toast.LENGTH_SHORT).show(); } - /** Expire every cookie HOST answers with, and return the names that were tried. */ - private static Set expire(final CookieManager cookies, final String host) { - final String url = BrowserLoginPolicy.SCHEME + "://" + host + "/"; - final String header = cookies.getCookie(url); - final Set tried = new LinkedHashSet(); - for (final String[] cookie : BrowserLoginCookies.split(header == null ? "" : header)) { - tried.add(cookie[0]); - } - for (final String value : BrowserLoginPolicy.expiryHeaders(header, host)) { - cookies.setCookie(url, value); - } - return tried; - } - /** "Cancel" */ public void onClickLoginCancel(final View view) { setResult(RESULT_CANCELED); diff --git a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java index bf73847..ecd65f8 100644 --- a/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java +++ b/app/src/main/java/com/httrack/android/BrowserLoginPolicy.java @@ -2,11 +2,7 @@ import java.net.URI; import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; import java.util.Locale; -import java.util.Set; /** * What the browser login opens, and what it changes about the crawl. A logged-in crawl has to @@ -82,86 +78,6 @@ static String hostOf(final String url) { return host.toLowerCase(Locale.ROOT); } - /** - * The values to hand {@code setCookie} to drop every cookie in COOKIEHEADER from HOST. - * CookieManager has no per-site removal, so a cookie goes by overwriting it with one already - * past its expiry. Chromium keys a cookie on its name, domain and path, so each name needs - * one value per domain it may have been set on: the host itself, the host as a domain - * cookie, then each parent. - * - * @param cookieHeader - * what {@code getCookie} answered for HOST, possibly null - * @param host - * the host those cookies came from - * @return the values, empty when there is nothing to drop - */ - static List expiryHeaders(final String cookieHeader, final String host) { - final List out = new ArrayList(); - if (cookieHeader == null || host == null) { - return out; - } - for (final String[] cookie : BrowserLoginCookies.split(cookieHeader)) { - /* Secure on every one, because Chromium refuses to set a __Host- or __Secure- cookie - without it, and those prefixes carry the session on most login sites. It is not part - of the key, so the value still drops a cookie that was not secure. */ - final String header = cookie[0] + "=; Max-Age=0; Path=/; Secure"; - out.add(header); - out.add(header + "; Domain=" + host); - for (final String parent : parentDomains(host)) { - out.add(header + "; Domain=" + parent); - } - } - return out; - } - - /** - * Whether any cookie this login tried to drop is still there. Asking only about those names - * keeps an unrelated consent or language cookie from reading as a live session, which would - * send the caller on to clear every other site. - * - * @param cookieHeader - * what {@code getCookie} answers now, possibly null - * @param dropped - * the names that were expired - * @return true when one of them survived - */ - static boolean stillSignedIn(final String cookieHeader, final Set dropped) { - if (cookieHeader == null) { - return false; - } - for (final String[] cookie : BrowserLoginCookies.split(cookieHeader)) { - if (dropped.contains(cookie[0])) { - return true; - } - } - return false; - } - - /** - * The parent domains of HOST, longest first, stopping before the last two labels. A session - * cookie is often set on a parent, and dropping it from the host alone would leave it live. - * - * @param host - * a lowercased host name - * @return the parents, empty for a host with no more than two labels - */ - static List parentDomains(final String host) { - final List out = new ArrayList(); - if (host == null) { - return out; - } - /* A public suffix needs no special case, because the browser refuses a cookie on one, so - an attempt at "co.uk" is turned down rather than widening anything. */ - for (int cut = host.indexOf('.'); cut != -1; cut = host.indexOf('.', cut + 1)) { - final String parent = host.substring(cut + 1); - if (parent.indexOf('.') == -1) { - break; - } - out.add(parent); - } - return out; - } - /** * The connection count to use, which only ever goes down. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99e7324..b5cdf7d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,7 +285,7 @@ Log in to the site here, then tap \"Use this login\". Google accounts refuse this window. Use this login Forget the saved login - The saved login is deleted. + The saved login is deleted. This also signs you out of every site in the browser inside this app. Type a web address first. Give the project a name first. This window opens https addresses only. diff --git a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java index 82c3c34..46f8d84 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginPolicyTest.java @@ -1,16 +1,9 @@ package com.httrack.android; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import java.util.Arrays; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - import org.junit.Test; /** Which address the login window opens, and what the hand-off changes about the crawl. */ @@ -114,65 +107,4 @@ public void anExistingLogoutRuleIsLeftAsTheUserWroteIt() { // Only the word itself counts, so a rule about another name still gets the filter. assertEquals("-*signout* -*logout*", BrowserLoginPolicy.rules("-*signout*")); } - - /** Chromium keys a cookie on name, domain and path, so each domain it may hold needs one. */ - @Test - public void everyDomainTheCookieCouldSitOnIsExpired() { - final List out = BrowserLoginPolicy.expiryHeaders("sid=abc", "www.a.example.com"); - assertEquals(Arrays.asList("sid=; Max-Age=0; Path=/; Secure", - "sid=; Max-Age=0; Path=/; Secure; Domain=www.a.example.com", - "sid=; Max-Age=0; Path=/; Secure; Domain=a.example.com", - "sid=; Max-Age=0; Path=/; Secure; Domain=example.com"), out); - } - - /** A two-label host has no parent, so its own domain form is the only one that reaches it. */ - @Test - public void theHostsOwnDomainFormIsAlwaysTried() { - assertEquals(Arrays.asList("sid=; Max-Age=0; Path=/; Secure", - "sid=; Max-Age=0; Path=/; Secure; Domain=a.example"), - BrowserLoginPolicy.expiryHeaders("sid=abc", "a.example")); - } - - /** Chromium refuses to set a __Host- or __Secure- cookie without Secure, and those carry - the session on most login sites. */ - @Test - public void everyValueCarriesSecure() { - for (final String value : BrowserLoginPolicy.expiryHeaders("__Host-sid=a; __Secure-t=b", - "a.example")) { - assertTrue(value, value.contains("; Secure")); - } - } - - @Test - public void nothingToExpireWhenTheHostAnswersWithNothing() { - assertTrue(BrowserLoginPolicy.expiryHeaders(null, "a.example").isEmpty()); - assertTrue(BrowserLoginPolicy.expiryHeaders("", "a.example").isEmpty()); - assertTrue(BrowserLoginPolicy.expiryHeaders("sid=abc", null).isEmpty()); - } - - /** Only the names we tried count, or an unrelated consent cookie clears every other site. */ - @Test - public void aSurvivingNameWeTriedMeansStillSignedIn() { - final Set tried = new LinkedHashSet(Arrays.asList("sid", "csrf")); - assertTrue(BrowserLoginPolicy.stillSignedIn("sid=abc", tried)); - assertTrue(BrowserLoginPolicy.stillSignedIn("lang=fr; csrf=x", tried)); - assertFalse(BrowserLoginPolicy.stillSignedIn("lang=fr; consent=1", tried)); - assertFalse(BrowserLoginPolicy.stillSignedIn("", tried)); - assertFalse(BrowserLoginPolicy.stillSignedIn(null, tried)); - assertFalse(BrowserLoginPolicy.stillSignedIn("sid=abc", new LinkedHashSet())); - } - - /** A session cookie is often set on a parent, so dropping it from the host alone leaves it. */ - @Test - public void theParentsStopBeforeTheLastTwoLabels() { - assertEquals(Arrays.asList("b.example.com", "example.com"), - BrowserLoginPolicy.parentDomains("a.b.example.com")); - assertEquals(Collections.singletonList("example.com"), - BrowserLoginPolicy.parentDomains("www.example.com")); - // Two labels are already the shortest a cookie may name, so there is no parent to try. - assertTrue(BrowserLoginPolicy.parentDomains("example.com").isEmpty()); - assertTrue(BrowserLoginPolicy.parentDomains("localhost").isEmpty()); - assertTrue(BrowserLoginPolicy.parentDomains(null).isEmpty()); - } - } diff --git a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java index a886945..4894866 100644 --- a/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java +++ b/app/src/test/java/com/httrack/android/BrowserLoginWiringTest.java @@ -156,23 +156,6 @@ public void theStagedWriteBuildsItsNameFromTheSharedHelper() throws IOException assertTrue(body, body.contains("staged.renameTo(file)")); } - /** The clear has to run off the shared helpers, which are where its rules are tested. */ - @Test - public void forgetExpiresOurOwnHostsBeforeReachingTheStore() throws IOException { - final String source = TestSources - .withoutCommentsAndStrings(TestSources.javaSource("BrowserLoginActivity")); - final String body = TestSources.balancedBlock(source, - TestSources.indexOf(source, "private void forget()")); - assertTrue(body, body.contains("BrowserLoginPolicy.stillSignedIn(")); - // Ordered, because a wide clear ahead of the check would make the narrow pass dead code. - final int checked = body.indexOf("stillSignedIn"); - final int wide = body.indexOf("removeAllCookies"); - assertTrue(body, checked != -1 && wide != -1 && checked < wide); - final String pass = TestSources.balancedBlock(source, - TestSources.indexOf(source, "private static Set expire(")); - assertTrue(pass, pass.contains("BrowserLoginPolicy.expiryHeaders(header, host)")); - } - /** -%K is read only when the engine accepts cookies, so the hand-off has to tick the box. */ @Test public void theHandOverTurnsCookiesOn() throws IOException {