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/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..c28dfd9 --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + 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]*?)`, '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, /