Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions android/app/src/debug/AndroidManifest.xml

This file was deleted.

8 changes: 0 additions & 8 deletions android/app/src/debug/res/xml/network_security_config.xml

This file was deleted.

1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions android/app/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Preserve plain-HTTP self-hosting support: this config takes precedence
over the manifest's usesCleartextTraffic flag. HTTPS still validates
certificates and hostnames, including CAs installed by the user. -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<!-- ZenNotes Cloud stays on the system store alone, as it was before user
CAs were trusted. A CA in the user store, whether added for a home
server or pushed by a device manager, can then vouch for a self-hosted
server but never for Cloud, so it cannot read the account token or a
vault's notes in transit. Cloud never speaks plain HTTP either. These
are the hosts of PRODUCTION_CLOUD_BASE_URL and LEGACY_CLOUD_BASE_URL in
src/bridge/mobile-cloud-auth.ts (network-security-config.test.ts keeps
them in step). The object-storage hosts of presigned uploads and
downloads are named by the service at run time and are not listed. -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">zennotes.org</domain>
<domain includeSubdomains="true">zennotes.laravel.cloud</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>
73 changes: 73 additions & 0 deletions src/bridge/network-security-config.test.ts
Original file line number Diff line number Diff line change
@@ -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(
/<!--[\s\S]*?-->/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(/<certificates\s+src="(\w+)"/g)].map((m) => m[1]),
domains: [...body.matchAll(/<domain([^>]*)>\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, /<debug-overrides|overridePins/)
})
Loading