From 9e8ce7cc038b6f33d49c4bf470b11066f4f0d64e Mon Sep 17 00:00:00 2001
From: "J. Lewis" <6710419+lewxdev@users.noreply.github.com>
Date: Wed, 28 Oct 2026 16:49:05 -0400
Subject: [PATCH 1/2] fix: trust user-installed CAs
---
.../src/debug/res/xml/network_security_config.xml | 8 --------
android/app/src/main/AndroidManifest.xml | 1 +
.../app/src/main/res/xml/network_security_config.xml | 12 ++++++++++++
3 files changed, 13 insertions(+), 8 deletions(-)
delete mode 100644 android/app/src/debug/res/xml/network_security_config.xml
create mode 100644 android/app/src/main/res/xml/network_security_config.xml
diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml
deleted file mode 100644
index c7dd079..0000000
--- a/android/app/src/debug/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3ab1cbc..2b95fd3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
+ android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..33a6533
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
From 157dafd1dea5e37666370bd5a67c9c9003d6546e Mon Sep 17 00:00:00 2001
From: Adib Hanna
Date: Wed, 28 Oct 2026 17:03:34 -0500
Subject: [PATCH 2/2] fix(network): ZenNotes Cloud keeps system trust when user
CAs are trusted
#141 trusts the user certificate store app-wide so a self-hosted server
behind a private CA connects (#140). That store also holds any CA a
device manager pushes or a user is talked into installing, and with the
base policy alone such a CA could vouch for zennotes.org as well. On an
emulator with a throwaway CA in the user store and a proxy presenting a
zennotes.org certificate signed by it, the #141 build completed the
handshake and handed its Cloud API request to the proxy.
Cloud's hosts (zennotes.org and the legacy zennotes.laravel.cloud,
subdomains included) now get a domain-config that trusts the system
store alone and refuses plain HTTP: the trust Cloud had before #141.
Every other host keeps the PR's policy, system and user CAs with plain
HTTP allowed for LAN servers. The same interception now fails the
handshake ("Trust anchor for certification path not found"), while a
self-hosted server behind the private CA still connects and syncs, in a
debug build and in a release-signed build.
The debug-only manifest that pointed at the old debug resource is gone
too: the main manifest declares the one policy for every build type.
It does not cover the object-storage hosts of presigned uploads and
downloads. The service names them at run time, and they never receive
the account token (DirectUploadPlugin refuses an Authorization header
on a signed upload).
network-security-config.test.ts checks the policy against the Cloud URLs
in mobile-cloud-auth.ts, so a future host move (2.28 made one) cannot
leave Cloud on user trust unnoticed.
---
android/app/src/debug/AndroidManifest.xml | 4 -
.../main/res/xml/network_security_config.xml | 16 ++++
src/bridge/network-security-config.test.ts | 73 +++++++++++++++++++
3 files changed, 89 insertions(+), 4 deletions(-)
delete mode 100644 android/app/src/debug/AndroidManifest.xml
create mode 100644 src/bridge/network-security-config.test.ts
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 68f484a..0000000
--- a/android/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
index 33a6533..c28dfd9 100644
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -9,4 +9,20 @@
+
+
+ zennotes.org
+ zennotes.laravel.cloud
+
+
+
+
diff --git a/src/bridge/network-security-config.test.ts b/src/bridge/network-security-config.test.ts
new file mode 100644
index 0000000..f69ca6d
--- /dev/null
+++ b/src/bridge/network-security-config.test.ts
@@ -0,0 +1,73 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { it } from 'node:test'
+
+// The app-wide Network Security Configuration (zennotesandroid#140, #141)
+// trusts user-installed CAs so a self-hosted server behind a private CA
+// connects, and keeps ZenNotes Cloud on the system store alone. Cloud moved
+// hosts once already (zennotes.laravel.cloud to zennotes.org), so the Cloud
+// hosts the policy names are checked against the URLs the app really uses.
+
+const root = new URL('../../', import.meta.url)
+const read = (path: string) => readFileSync(new URL(path, root), 'utf8')
+const policy = read('android/app/src/main/res/xml/network_security_config.xml').replace(
+ //g,
+ ''
+)
+
+interface TrustConfig {
+ cleartext: string | null
+ anchors: string[]
+ domains: { name: string; includeSubdomains: boolean }[]
+}
+
+function parse(tag: 'base-config' | 'domain-config'): TrustConfig[] {
+ return [...policy.matchAll(new RegExp(`<${tag}([^>]*)>([\\s\\S]*?)${tag}>`, 'g'))].map(
+ ([, attributes, body]) => ({
+ cleartext: /cleartextTrafficPermitted="(\w+)"/.exec(attributes)?.[1] ?? null,
+ anchors: [...body.matchAll(/ m[1]),
+ domains: [...body.matchAll(/]*)>\s*([^<\s]+)\s*<\/domain>/g)].map((m) => ({
+ name: m[2],
+ includeSubdomains: /includeSubdomains="true"/.test(m[1])
+ }))
+ })
+ )
+}
+
+function cloudHosts(): string[] {
+ const source = read('src/bridge/mobile-cloud-auth.ts')
+ return ['PRODUCTION_CLOUD_BASE_URL', 'LEGACY_CLOUD_BASE_URL'].map((name) => {
+ const url = new RegExp(`const ${name} = '([^']+)'`).exec(source)?.[1]
+ assert.ok(url, `${name} not found in mobile-cloud-auth.ts`)
+ return new URL(url).hostname
+ })
+}
+
+it('trusts system and user CAs and allows plain HTTP for self-hosted servers', () => {
+ const [base, ...extra] = parse('base-config')
+ assert.equal(extra.length, 0)
+ assert.deepEqual(base.anchors.sort(), ['system', 'user'])
+ assert.equal(base.cleartext, 'true')
+})
+
+it('keeps every ZenNotes Cloud host on system trust only, over HTTPS', () => {
+ const configs = parse('domain-config')
+ for (const host of cloudHosts()) {
+ const matching = configs.filter((config) =>
+ config.domains.some(
+ (d) => host === d.name || (d.includeSubdomains && host.endsWith(`.${d.name}`))
+ )
+ )
+ assert.equal(matching.length, 1, `${host} needs exactly one domain-config`)
+ assert.deepEqual(matching[0].anchors, ['system'], `${host} must not trust user CAs`)
+ assert.equal(matching[0].cleartext, 'false', `${host} must refuse plain HTTP`)
+ }
+})
+
+it('is the one policy, declared by the main manifest for every build type', () => {
+ assert.match(
+ read('android/app/src/main/AndroidManifest.xml'),
+ /android:networkSecurityConfig="@xml\/network_security_config"/
+ )
+ assert.doesNotMatch(policy, /