Files
webmail/lib/oauth/custom-upstream.ts
T
Linus Rath 2edd6b5ea7 fix: hold forced policies against spoofed plugins, recommended values and stopped servers
A forced value could be got around from inside the app. Only a forced
value may now decide what's allowed: the keys that do are forced-only,
so a recommended one no longer acts as forced. With a plugin list a
package can name any id, so uploads are refused and only managed
plugins can match. A server taken off AllowedJmapServers stops its
accounts without signing out: client, push and sync stop, the switcher
drops them, restore and refresh answer 403 with the cookie kept, and
listing it again resumes with no sign-in. OAuth and SSO starts check
the list too.

- `policy.onWipeOfflineData` is gone from the bridge: main runs the wipe
- `KeystoreAllowedOrigins` added, and `POLICY_CONSUMERS` names who acts
  on each key or who still owes it
- a Bulwark server's name must be plain after parsing, a wildcard in the
  auth lists needs a domain of two labels
2026-11-03 12:06:21 +01:00

81 lines
3.5 KiB
TypeScript

/**
* Which server minted a slot's refresh token, for the desktop build.
*
* The token routes know a server only through `jmap_rts`, the id of an entry in
* `jmapServers`, and otherwise fall back to the configured server. A standalone
* desktop app has neither: every account is a server the user typed. So refresh,
* revocation and sign-out would throw "OAuth misconfigured", and the account
* would first look unreachable and then be signed out.
*
* The record is sealed so only this server can have written it. Whether the
* address may be reached isn't stored: it is checked again on every read, by
* the same rule as the sign-in routes.
*/
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getSignInCookieOptions } from '@/lib/oauth/cookie-config';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { mailServerRefusedByDevicePolicy } from '@/lib/desktop/server-policy';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
const COOKIE = 'jmap_rtu';
export interface CustomUpstream {
serverUrl: string;
clientId: string;
/** The user approved this server, so it may be on a private address. */
trusted: boolean;
}
export function customUpstreamCookieName(slot: number): string {
return slot === 0 ? COOKIE : `${COOKIE}_${slot}`;
}
/** Null when the address isn't one the sign-in routes would accept right now. */
export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (mailServerRefusedByDevicePolicy(serverUrl)) return null;
return checkedUpstream(serverUrl, clientId);
}
async function checkedUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
await configManager.ensureLoaded();
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get<string>('jmapServerUrl', ''), servers) !== null;
if (!trusted && !(await isPublicHttpUrl(serverUrl))) return null;
return { serverUrl: serverUrl.replace(/\/+$/, ''), clientId, trusted };
}
export function writeCustomUpstream(
cookieStore: CookieStore,
slot: number,
upstream: Pick<CustomUpstream, 'serverUrl' | 'clientId'>,
remember: boolean,
): void {
const value = encryptPayload({ s: upstream.serverUrl, c: upstream.clientId }, 'custom-upstream');
cookieStore.set(customUpstreamCookieName(slot), value, getSignInCookieOptions(remember));
}
export async function readCustomUpstream(cookieStore: CookieStore, slot: number): Promise<CustomUpstream | null> {
const value = cookieStore.get(customUpstreamCookieName(slot))?.value;
if (!value) return null;
let payload: Record<string, unknown> | null;
try {
payload = decryptPayload(value, 'custom-upstream');
} catch {
return null;
}
// Without the device's policy: a sign-out still has to revoke its token at a server
// that came off the list. A refresh checks the policy itself.
return payload ? checkedUpstream(payload.s, payload.c) : null;
}
export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void {
const name = customUpstreamCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}