fix(tauri): preserve HTTP contracts when bypassing IPC
Keep IPC on captured native fetch during font proxying. Preserve URL inputs, configured timeouts, and generated multipart headers; cover native routing and reject lookalike IPC hostnames.
This commit is contained in:
parent
3f48e4251c
commit
e342cfca30
|
|
@ -1,3 +1,4 @@
|
|||
import type { FetchFunction } from '@/app/http/types'
|
||||
|
||||
export interface ProxyHttpHeader {
|
||||
name: string
|
||||
|
|
@ -71,23 +72,45 @@ export function withAbortSignal<T>(promise: Promise<T>, signal: AbortSignal): Pr
|
|||
export interface TauriFetchOptions {
|
||||
timeoutMs?: number
|
||||
maxResponseBytes?: number
|
||||
nativeFetch?: FetchFunction
|
||||
}
|
||||
|
||||
const nativeFetch: typeof fetch = globalThis.fetch.bind(globalThis)
|
||||
|
||||
export function createTauriFetch(options: TauriFetchOptions = {}): FetchFunction {
|
||||
return (input, init) =>
|
||||
executeTauriFetch(
|
||||
options.nativeFetch ?? nativeFetch,
|
||||
input,
|
||||
init,
|
||||
options.maxResponseBytes,
|
||||
options.timeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
export async function tauriFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
maxResponseBytes?: number,
|
||||
timeoutMs?: number
|
||||
): Promise<Response> {
|
||||
const parsedURL = new URL(typeof input === 'object' ? (input as Request).url : input)
|
||||
return executeTauriFetch(nativeFetch, input, init, maxResponseBytes, timeoutMs)
|
||||
}
|
||||
|
||||
async function executeTauriFetch(
|
||||
fetcher: FetchFunction,
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
maxResponseBytes?: number,
|
||||
timeoutMs?: number
|
||||
): Promise<Response> {
|
||||
const parsedURL = new URL(input instanceof Request ? input.url : input.toString())
|
||||
const isIpcURL =
|
||||
parsedURL.protocol === 'ipc:' ||
|
||||
((parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:') &&
|
||||
parsedURL.hostname === 'ipc.localhost')
|
||||
if (isIpcURL) {
|
||||
return nativeFetch(input as RequestInfo, init)
|
||||
return fetcher(input, init)
|
||||
}
|
||||
|
||||
const request = new Request(input, init)
|
||||
|
|
@ -95,7 +118,9 @@ export async function tauriFetch(
|
|||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
request.signal.throwIfAborted()
|
||||
|
||||
let bodyData: Uint8Array | undefined = undefined
|
||||
// Capture generated multipart headers before consuming the Request body.
|
||||
const headers = headersToProxyHeaders(request.headers)
|
||||
let bodyData: Uint8Array | undefined
|
||||
if (request.body != null) {
|
||||
const buffer = await request.arrayBuffer()
|
||||
request.signal.throwIfAborted()
|
||||
|
|
@ -105,7 +130,7 @@ export async function tauriFetch(
|
|||
const payload: ProxyHttpRequest = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headersToProxyHeaders(request.headers),
|
||||
headers,
|
||||
body: bodyData ? Array.from(bodyData) : undefined,
|
||||
max_response_bytes: maxResponseBytes,
|
||||
follow_redirects: request.redirect === 'follow',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
createTauriFetch,
|
||||
tauriFetch,
|
||||
type ProxyHttpRequest,
|
||||
type ProxyHttpResponse
|
||||
|
|
@ -43,6 +44,44 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe('tauriFetch', () => {
|
||||
test('routes IPC through the captured native fetch without invoking the HTTP proxy', async () => {
|
||||
await mockTauriIPC(() => {
|
||||
throw new Error('IPC recursively entered the HTTP proxy')
|
||||
})
|
||||
const inputs = [
|
||||
'ipc://localhost/plugin%3Ahttp%7Cfetch',
|
||||
'http://ipc.localhost/plugin%3Ahttp%7Cfetch',
|
||||
new URL('https://ipc.localhost/plugin%3Ahttp%7Cfetch'),
|
||||
new Request('http://ipc.localhost/plugin%3Ahttp%7Cfetch')
|
||||
]
|
||||
const received: Array<RequestInfo | URL> = []
|
||||
const fetcher = createTauriFetch({
|
||||
nativeFetch: async (input) => {
|
||||
received.push(input)
|
||||
return new Response('native IPC')
|
||||
}
|
||||
})
|
||||
for (const input of inputs) expect(await (await fetcher(input)).text()).toBe('native IPC')
|
||||
expect(received).toEqual(inputs)
|
||||
})
|
||||
|
||||
test('does not bypass HTTP proxy for URLs merely containing the IPC hostname', async () => {
|
||||
const received: string[] = []
|
||||
await mockTauriIPC((command, args) => {
|
||||
expect(command).toBe('proxy_http_request')
|
||||
received.push((args as InvokeArgs).request.url)
|
||||
return { status: 204, headers: [], body: [] }
|
||||
})
|
||||
const fetcher = createTauriFetch({
|
||||
nativeFetch: async () => {
|
||||
throw new Error('External URL bypassed the HTTP proxy')
|
||||
}
|
||||
})
|
||||
const urls = ['https://ipc.localhost.example.test/', 'https://example.test/ipc.localhost']
|
||||
for (const url of urls) await fetcher(url)
|
||||
expect(received).toEqual(urls)
|
||||
})
|
||||
|
||||
test('passes request timeout metadata to the desktop HTTP command', async () => {
|
||||
let captured: InvokeArgs | null = null
|
||||
await mockTauriIPC((command, args) => {
|
||||
|
|
@ -55,6 +94,12 @@ describe('tauriFetch', () => {
|
|||
}
|
||||
})
|
||||
|
||||
const response = await createTauriFetch({ timeoutMs: 15_000 })('https://example.test/check', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"ok":true}'
|
||||
})
|
||||
|
||||
if (!captured) throw new Error('Expected proxy_http_request to be invoked')
|
||||
expect(response.status).toBe(201)
|
||||
expect(response.headers.get('x-open-pencil')).toBe('ok')
|
||||
|
|
@ -65,6 +110,18 @@ describe('tauriFetch', () => {
|
|||
expect(captured.request.body).toEqual([...new TextEncoder().encode('{"ok":true}')])
|
||||
})
|
||||
|
||||
test('accepts URL objects without treating them as Requests', async () => {
|
||||
let captured: InvokeArgs | null = null
|
||||
await mockTauriIPC((command, args) => {
|
||||
expect(command).toBe('proxy_http_request')
|
||||
captured = args as InvokeArgs
|
||||
return { status: 204, headers: [], body: [] }
|
||||
})
|
||||
await tauriFetch(new URL('https://example.test/url-object'))
|
||||
if (!captured) throw new Error('Expected proxy_http_request to be invoked')
|
||||
expect(captured.request.url).toBe('https://example.test/url-object')
|
||||
})
|
||||
|
||||
test('forwards bodies from Request inputs', async () => {
|
||||
let captured: InvokeArgs | null = null
|
||||
await mockTauriIPC((command, args) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue