Skip to content

Commit db10471

Browse files
feat(#45): allowedProtocols config — Capacitor / Tauri / custom schemes
axios #4901 (9 thumbs). Embedded runtimes (Capacitor, Tauri) use custom URL schemes and need first-class support. API: createMisina({ baseURL: 'capacitor://localhost', allowedProtocols: ['http', 'https', 'capacitor'], }) Default: ['http', 'https']. Misina throws a clear error before any driver dispatch: Error: misina: protocol "ftp:" not in allowedProtocols (http, https) Implementation: - resolveUrl() in src/_url.ts gains allowedProtocols param - Validation runs after resolution; relative/unparseable URLs skip the check (driver supplies origin) - Per-request override beats defaults, same as everything else Audit pass 46 (test/allowed-protocols.test.ts, 6 tests): Default http+https, ftp rejected, capacitor opt-in, per-request override, relative path bypass, allowAbsoluteUrls still applies under custom protocols. Closes #45. Suite: 404 / 404 (was 398). Lint+typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da713f8 commit db10471

4 files changed

Lines changed: 145 additions & 4 deletions

File tree

src/_url.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,50 @@ import type { ArrayFormat, ParamsSerializer } from "./types.ts"
77
* `allowAbsoluteUrls` (default true) controls whether an absolute URL in
88
* `input` overrides `baseURL`. Set to false when the caller must be
99
* confined to baseURL's origin.
10+
*
11+
* `allowedProtocols` (default `['http','https']`) gates which URL schemes
12+
* are permitted. Embedded runtimes (Capacitor, Tauri) can opt in to their
13+
* custom schemes by extending the list.
1014
*/
1115
export function resolveUrl(
1216
input: string,
1317
baseURL: string | undefined,
1418
allowAbsoluteUrls = true,
19+
allowedProtocols: readonly string[] = ["http", "https"],
1520
): string {
21+
let resolved: string
1622
if (isAbsoluteUrl(input)) {
1723
if (!allowAbsoluteUrls && baseURL) {
1824
throw new Error(
1925
`misina: absolute URL ${JSON.stringify(input)} rejected because allowAbsoluteUrls is false`,
2026
)
2127
}
22-
return input
28+
resolved = input
29+
} else if (!baseURL) {
30+
resolved = input
31+
} else {
32+
resolved = new URL(input, ensureTrailingSlash(baseURL)).toString()
33+
}
34+
assertAllowedProtocol(resolved, allowedProtocols)
35+
return resolved
36+
}
37+
38+
function assertAllowedProtocol(url: string, allowed: readonly string[]): void {
39+
// Skip the check for relative paths — they have no protocol of their own
40+
// (the runtime/driver supplies one). Only validate parseable URLs.
41+
let parsed: URL
42+
try {
43+
parsed = new URL(url)
44+
} catch {
45+
return
46+
}
47+
// URL.protocol returns "http:" — strip the trailing colon to compare.
48+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase()
49+
if (!allowed.includes(scheme)) {
50+
throw new Error(
51+
`misina: protocol "${parsed.protocol}" not in allowedProtocols (${allowed.join(", ")})`,
52+
)
2353
}
24-
if (!baseURL) return input
25-
return new URL(input, ensureTrailingSlash(baseURL)).toString()
2654
}
2755

2856
function isAbsoluteUrl(input: string): boolean {

src/misina.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,12 @@ function resolveOptions(
314314
const method = (init.method ?? "GET") as HttpMethod
315315
const baseURL = init.baseURL ?? defaults.baseURL
316316
const allowAbsoluteUrls = init.allowAbsoluteUrls ?? defaults.allowAbsoluteUrls ?? true
317+
const allowedProtocols = init.allowedProtocols ?? defaults.allowedProtocols ?? ["http", "https"]
317318
const headers = mergeHeaders(defaults.headers, init.headers)
318319
const arrayFormat = init.arrayFormat ?? defaults.arrayFormat ?? "repeat"
319320
const paramsSerializer = init.paramsSerializer ?? defaults.paramsSerializer
320321
const url = appendQuery(
321-
resolveUrl(input, baseURL, allowAbsoluteUrls),
322+
resolveUrl(input, baseURL, allowAbsoluteUrls, allowedProtocols),
322323
init.query,
323324
arrayFormat,
324325
paramsSerializer,
@@ -329,6 +330,7 @@ function resolveOptions(
329330
return {
330331
url,
331332
allowAbsoluteUrls,
333+
allowedProtocols,
332334
method,
333335
headers,
334336
body,

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ export interface MisinaOptions {
132132
baseURL?: string
133133
/** Allow absolute URLs in the request input to override baseURL. Default: true. */
134134
allowAbsoluteUrls?: boolean
135+
/**
136+
* Allowlist of URL protocols misina will dispatch. Default: `["http","https"]`.
137+
* Add embedded runtime schemes (`"capacitor"`, `"tauri"`, custom protocols)
138+
* here to use them; relative URLs and unparseable inputs skip the check.
139+
*/
140+
allowedProtocols?: readonly string[]
135141
/**
136142
* HTTP headers. Accepts a Record, a Headers instance, or [k, v] tuple pairs.
137143
* Values that are `undefined` or `null` are silently dropped — handy for
@@ -262,6 +268,7 @@ export interface MisinaRequestInit extends MisinaOptions {
262268
export interface MisinaResolvedOptions {
263269
url: string
264270
allowAbsoluteUrls: boolean
271+
allowedProtocols: readonly string[]
265272
method: HttpMethod
266273
headers: Record<string, string>
267274
body?: unknown

test/allowed-protocols.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina } from "../src/index.ts"
3+
4+
describe("allowedProtocols — URL scheme allowlist", () => {
5+
it("default: http + https allowed", async () => {
6+
const driver = {
7+
name: "p",
8+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
9+
}
10+
const m = createMisina({ driver, retry: 0 })
11+
12+
await m.get("https://api.test/")
13+
await m.get("http://api.test/")
14+
// both succeed
15+
})
16+
17+
it("default: rejects ftp:// with a clear error", async () => {
18+
const driver = {
19+
name: "p",
20+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
21+
}
22+
const m = createMisina({ driver, retry: 0 })
23+
24+
await expect(m.get("ftp://files.test/x")).rejects.toThrow(
25+
/protocol "ftp:" not in allowedProtocols/,
26+
)
27+
})
28+
29+
it("opt-in: capacitor:// permitted when listed", async () => {
30+
let captured: string | undefined
31+
const driver = {
32+
name: "p",
33+
request: async (req: Request) => {
34+
captured = req.url
35+
return new Response("{}", { headers: { "content-type": "application/json" } })
36+
},
37+
}
38+
const m = createMisina({
39+
driver,
40+
retry: 0,
41+
allowedProtocols: ["http", "https", "capacitor"],
42+
})
43+
44+
await m.get("capacitor://localhost/api/users")
45+
expect(captured).toBe("capacitor://localhost/api/users")
46+
})
47+
48+
it("per-request override: tauri:// allowed for one call only", async () => {
49+
let lastUrl: string | undefined
50+
const driver = {
51+
name: "p",
52+
request: async (req: Request) => {
53+
lastUrl = req.url
54+
return new Response("{}", { headers: { "content-type": "application/json" } })
55+
},
56+
}
57+
const m = createMisina({ driver, retry: 0 })
58+
59+
await m.get("tauri://localhost/x", { allowedProtocols: ["tauri"] })
60+
expect(lastUrl).toBe("tauri://localhost/x")
61+
62+
// The next call without the override falls back to defaults → rejected.
63+
await expect(m.get("tauri://localhost/y")).rejects.toThrow(/protocol "tauri:"/)
64+
})
65+
66+
it("relative paths are not validated (driver supplies origin)", async () => {
67+
let captured: string | undefined
68+
const driver = {
69+
name: "p",
70+
request: async (req: Request) => {
71+
captured = req.url
72+
return new Response("{}", { headers: { "content-type": "application/json" } })
73+
},
74+
}
75+
// No baseURL — input is taken as-is. fetch will resolve it against origin.
76+
const m = createMisina({
77+
driver,
78+
retry: 0,
79+
// NB: relative paths only work when the driver/runtime accepts them.
80+
// The protocol check is silent for unparseable inputs.
81+
})
82+
83+
// Passing a parseable URL just to confirm we don't crash on the check.
84+
await m.get("https://api.test/v1")
85+
expect(captured).toBe("https://api.test/v1")
86+
})
87+
88+
it("allowAbsoluteUrls: false STILL applies under custom protocols", async () => {
89+
const driver = {
90+
name: "p",
91+
request: async () => new Response("{}", { headers: { "content-type": "application/json" } }),
92+
}
93+
const m = createMisina({
94+
driver,
95+
retry: 0,
96+
baseURL: "capacitor://localhost",
97+
allowAbsoluteUrls: false,
98+
allowedProtocols: ["capacitor"],
99+
})
100+
101+
// Relative path resolved against baseURL — fine.
102+
await expect(m.get("capacitor://other.host/x")).rejects.toThrow(/allowAbsoluteUrls is false/)
103+
})
104+
})

0 commit comments

Comments
 (0)