fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627)

This commit is contained in:
Linus Rath
2026-08-29 18:03:24 +02:00
parent 69bedf0b26
commit 6f747db858
3 changed files with 74 additions and 16 deletions
@@ -141,7 +141,9 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
connectTimeout = timeoutMs connectTimeout = timeoutMs
readTimeout = timeoutMs readTimeout = timeoutMs
requestMethod = method requestMethod = method
instanceFollowRedirects = true // Redirects are resolved in JS (lib/client-cert.ts) so a POST
// stays a POST and credentials never follow a cross-host hop.
instanceFollowRedirects = false
if (bodyBytes != null) { if (bodyBytes != null) {
doOutput = true doOutput = true
setFixedLengthStreamingMode(bodyBytes.size) setFixedLengthStreamingMode(bodyBytes.size)
+1 -1
View File
@@ -601,7 +601,7 @@ export class JMAPClient {
controller?.abort(); controller?.abort();
}, timeoutMs); }, timeoutMs);
try { try {
return await secureFetch(url, controller ? { ...init, signal: controller.signal } : init); return await secureFetch(url, { ...init, ...(controller ? { signal: controller.signal } : {}), timeoutMs });
} catch (error) { } catch (error) {
if (timedOut) throw new RequestTimeoutError(timeoutMs); if (timedOut) throw new RequestTimeoutError(timeoutMs);
throw error; throw error;
+70 -14
View File
@@ -148,9 +148,47 @@ function buildResponse(raw: {
* on iOS), it just calls the platform `fetch` and the caller pays no * on iOS), it just calls the platform `fetch` and the caller pays no
* bridge overhead. * bridge overhead.
*/ */
export interface SecureFetchInit extends RequestInit {
/** Native-path deadline (ms); blob transfers pass a longer one. */
timeoutMs?: number;
}
const MAX_REDIRECTS = 5;
function originOf(url: string): string {
const m = /^(https?):\/\/([^/?#]+)/i.exec(url);
return m ? `${m[1].toLowerCase()}://${m[2].toLowerCase()}` : '';
}
function hostOnly(origin: string): string {
return origin.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
}
/**
* May the Authorization header (and the original method + body) follow a
* redirect to `target`? Only within the same host, or an http→https upgrade
* of it - the same rule the webmail's Stalwart passthrough applies.
*/
export function mayFollowRedirect(from: string, target: string): boolean {
const a = originOf(from);
const b = originOf(target);
if (!a || !b) return false;
if (a === b) return true;
return hostOnly(a) === hostOnly(b) && a.startsWith('http://') && b.startsWith('https://');
}
function resolveRedirect(base: string, location: string): string {
if (/^https?:\/\//i.test(location)) return location;
const origin = originOf(base);
if (location.startsWith('/')) return origin + location;
const path = base.slice(origin.length).replace(/[?#].*$/, '');
const dir = path.slice(0, path.lastIndexOf('/') + 1) || '/';
return origin + dir + location;
}
export async function secureFetch( export async function secureFetch(
url: string, url: string,
init?: RequestInit, init?: SecureFetchInit,
): Promise<Response> { ): Promise<Response> {
const native = getNative(); const native = getNative();
if (!native) return fetch(url, init); if (!native) return fetch(url, init);
@@ -167,19 +205,37 @@ export async function secureFetch(
const decoded = base64ToBytes(bodyBase64); const decoded = base64ToBytes(bodyBase64);
headers['Content-Length'] = String(decoded.byteLength); headers['Content-Length'] = String(decoded.byteLength);
} }
try { const timeoutMs = init?.timeoutMs ?? 30_000;
const raw = await native.fetchSecure({
url, // Redirects are handled here rather than by HttpURLConnection, which would
method, // turn a POST into a GET on 301/302 (Stalwart answers a GET /jmap/ with a
headers, // 404 problem+json, #627) and re-send the Authorization header to a
bodyBase64, // foreign host. Same host / https upgrade keeps method, body and header;
timeoutMs: 30_000, // anything else is returned to the caller as the 3xx it is.
}); let currentUrl = url;
for (let hop = 0; ; hop++) {
let raw: Awaited<ReturnType<typeof native.fetchSecure>>;
try {
raw = await native.fetchSecure({ url: currentUrl, method, headers, bodyBase64, timeoutMs });
} catch (err) {
// Surface as a TypeError to mimic fetch's network-error contract; keep
// the original message for diagnostics.
const msg = err instanceof Error ? err.message : String(err);
throw new TypeError(`secureFetch failed: ${msg}`);
}
const status = raw.status;
const location = raw.headers?.Location ?? raw.headers?.location;
if (
(status === 301 || status === 302 || status === 303 || status === 307 || status === 308) &&
location &&
hop < MAX_REDIRECTS
) {
const next = resolveRedirect(currentUrl, location);
if (mayFollowRedirect(currentUrl, next)) {
currentUrl = next;
continue;
}
}
return buildResponse(raw); return buildResponse(raw);
} catch (err) {
// Surface as a TypeError to mimic fetch's network-error contract; keep
// the original message for diagnostics.
const msg = err instanceof Error ? err.message : String(err);
throw new TypeError(`secureFetch failed: ${msg}`);
} }
} }