mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: resolve redirects in JS for client-certificate requests and pass blob deadlines to the native path (#627)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user