From ce97232eb2187dbca3ad9ee7e258f428098585bb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:03:23 +0200 Subject: [PATCH] fix: allow cleartext server URLs only on local hosts, name the mail domain as TOTP issuer and guard external links --- src/lib/open-url.ts | Bin 0 -> 2657 bytes src/lib/server-discovery.ts | 24 ++++++++++++++++++++++++ src/lib/totp.ts | 8 ++++++-- 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 src/lib/open-url.ts diff --git a/src/lib/open-url.ts b/src/lib/open-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cd1aa7448632e52a1f22dbbc19507701732f6ba GIT binary patch literal 2657 zcmai0ZEqS!5bkIGL?=om4hQbqkHvK?%dy%>L_~J1esD-x?&i$a$1d7kz|=^mu)8Csa7 zsx*wix6h{x>)MktVo3$qyd%GGrOLTaV{HPP(9g2}{`vRi+xX)cfaca<`2>r{a5<(* zEeuS-jGO4-lv|56zMoqsZh%t*5+IN@z)(1=i!O~zT`_Erv#~@|I~tQ?l~1X%h==5a z?~1W7)voBxl?!VsnCDa>ZJr8CD`bf!F=5KMoHKlxTT=5mcG;&iG}T%*#Y`PBfYyo0 zD-E|)@h#xZUyrL@($xVA2!rZsJ6kp^i#R^v5fJ7dC7WZ#C zF0Ocjq{w>x=G&tV9MyTI5;c&bH!%*&WZ`t5d~irdXZu2rWENFL4=n<<1mO&rvQHoV zQF5bte~skp$KKDc(%$H()6aCu_neCRg)g$O#aH$O!h!{?V+jRzF#JZ&9g~YNAq*Rq>39QaV(-0@mau@@TH|y=g%W-qB z(&<$Bq`7c-15~`^KRFNRyfwHD!mf0f3XI@vMfp@Y(21R=9!*&l>@tW0dRs0@l_yGv z6u_m?Jenc&3hmXT;-2CA(8CIovAxD%&+#qa?+W-LyZNs7{okW34eUcxUX*R0OH=E^ z*O;_Vp#%E#KV~1ZqyY z-_eAjn~YXgvHcr%b;(cdO42I}1!C3Gp~#;^meRF{xI%BEn$*Uk>MG~Z#s%_C9s&%# zBL-92P@E{gJB_8KF4)!vy0kop7(=}WP}^uO!#8SBYBxLWLx?Q^nuc()_(6w?u~U=u z$;LbOUfs%8B2c#{I&|{->0xDZt_Ett)iWb9O!imnu52M+lF+6nBkLqL!>g!#(6+i| zbwKCr=0hWcV6C^+5`7%3Sc?5qUl1c0TeMOh-BqI53qodP8Sf7BNuXAurBC`>`L)S+ z+9kNRrQYlDF4=kT{V%fNgdR3%`1cr-+Vd|>nqNOrISf6B(TQk63PqeHyRJS|mUy^x z*tOE<0wQfH+KCRf;vr>%ho)s}8}#@{@l7hbJ!tQ6Os&wsaTx5QP7+rqIo4XbOg?ey zq?qw8B+U}VjBLR~Cei6BwPSgO(WmY17mqgFf`~tjrdf@26D=Qqm_j{Q8q-3`LO(o6 zXdAMN;KBJ=SqOMK-Z{DX?2a%f1B9sGxY+SDLOhSZG0J>c<4*v!Yj$A4n)e7Fb;dja z)tgi#k!T&-?0oHc5QvfSpHuL4V*!KdnXS>z>_bCTkE+`6GpO@+bp|A9$Opqe+$*cCLgumQ$^KS?z=n$6FnYy%PfA4U6R+yDRo literal 0 HcmV?d00001 diff --git a/src/lib/server-discovery.ts b/src/lib/server-discovery.ts index 89d5f921..2254f67e 100644 --- a/src/lib/server-discovery.ts +++ b/src/lib/server-discovery.ts @@ -12,6 +12,24 @@ import { secureFetch } from './client-cert'; export const DISCOVERY_TIMEOUT_MS = 2500; const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i; + +/** Loopback, the Android emulator's host alias, RFC 1918 ranges and `.local`. */ +export function isLocalHost(url: string): boolean { + const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#:]+)/i.exec(url); + const host = (m ? m[1] : '').toLowerCase(); + if (!host) return false; + if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '10.0.2.2') return true; + if (host.endsWith('.local') || host.endsWith('.localhost')) return true; + const ip = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host); + if (!ip) return false; + const [a, b] = [Number(ip[1]), Number(ip[2])]; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +/** True when a normalised server URL uses plain http. */ +export function isCleartextUrl(url: string): boolean { + return /^http:\/\//i.test(url); +} const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/; /** @@ -32,6 +50,12 @@ export function normalizeServerUrl(input: string): string | null { value = `https://${value}`; } + // Basic credentials travel in every request. Cleartext is only acceptable + // for a server on the developer's own machine / LAN; on a public host it + // would fail opaquely on Android anyway (no cleartext traffic allowed) and + // leak the password on iOS. + if (/^http:\/\//i.test(value) && !isLocalHost(value)) return null; + // Someone pasting the endpoint they found in the webmail's settings gets the // same result as someone typing the address they use in the browser. value = value diff --git a/src/lib/totp.ts b/src/lib/totp.ts index cd0d0257..b75b10e8 100644 --- a/src/lib/totp.ts +++ b/src/lib/totp.ts @@ -46,9 +46,13 @@ export interface TotpEnrolment { url: string; } -export function generateTotpEnrolment(accountLabel: string): TotpEnrolment { +export function generateTotpEnrolment(accountLabel: string, issuerOverride?: string): TotpEnrolment { const secret = base32Encode(randomBytes(SECRET_BYTES)); - const issuer = 'Stalwart'; + // The issuer is what the authenticator app shows above the code; the + // account's mail domain identifies the service far better than the + // server software's name. + const at = accountLabel.lastIndexOf('@'); + const issuer = issuerOverride || (at > 0 ? accountLabel.slice(at + 1) : 'Bulwark'); const label = accountLabel || 'account'; const params = new URLSearchParams({ secret,