diff --git a/alias.ts b/alias.ts index d916b2c47..08db230d3 100644 --- a/alias.ts +++ b/alias.ts @@ -34,6 +34,7 @@ export const alias = { 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), 'devframe/utils/nostics': r('devframe/src/utils/nostics.ts'), 'devframe/utils/open': r('devframe/src/utils/open.ts'), + 'devframe/utils/origin': r('devframe/src/utils/origin.ts'), 'devframe/utils/remote-assets': r('devframe/src/utils/remote-assets.ts'), 'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'), 'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'), diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index e953731da..df9a91ff4 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -67,6 +67,8 @@ Devtools ready — authenticate this browser: http://localhost:3000/#devframe_ot The code rides the URL **fragment** (`#devframe_otp=…`), which browsers never send to the server, keeping the single-use code out of access logs and `Referer` headers. `connectDevframe` reads it, exchanges it, and strips it from the URL. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal). +The link points at the **public origin**. A standalone dev server derives it from its own bound address; an owned listener uses that address regardless of any inbound `Host` header. A handler or middleware without an explicit `origin` derives one from a request only when the request's own origin is loopback or exactly matches an `allowedOrigins` entry — a raw inbound authority and forwarded headers are never trusted. Set `origin` explicitly for non-loopback handler deployments (behind a proxy, on a LAN, or on a public host) so the magic link always resolves to the address you intend. + For your own auth UI, disable built-in handling with `otpParam: false`, then call `authenticateWithUrlOtp(rpc)` or `consumeOtpFromUrl()` from `devframe/client`. ## Practices for tools built on devframe diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index b4f411967..145d45a63 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -129,7 +129,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so ## Auth -The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known (the first request, or the `origin` option). Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. +The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known — from the `origin` option, or derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. ## Relation to the other adapters diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 95d9e729e..60eef7fc5 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -56,6 +56,7 @@ "./utils/nanoid": "./dist/utils/nanoid.mjs", "./utils/nostics": "./dist/utils/nostics.mjs", "./utils/open": "./dist/utils/open.mjs", + "./utils/origin": "./dist/utils/origin.mjs", "./utils/remote-assets": "./dist/utils/remote-assets.mjs", "./utils/simple-schema": "./dist/utils/simple-schema.mjs", "./utils/serve-static": "./dist/utils/serve-static.mjs", diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index b3c1075ee..0da1e109e 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -352,6 +352,77 @@ describe('adapters/handler', () => { } }) + // The auth-link origin is derived from the served request's URL (the fetch + // handler ignores the `Host` header — that path is `nodeMiddleware`'s), so + // each case just points a request at the origin under test and inspects the + // one-time banner (`console.log`). + async function withBannerSpy( + id: string, + extra: Partial[1]>, + run: (devtools: ReturnType, spy: ReturnType) => Promise, + ): Promise { + const wsPort = await getPort({ host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra }) + try { + await devtools.ready + await run(devtools, spy) + } + finally { + spy.mockRestore() + await devtools.close() + } + } + const hit = (devtools: ReturnType, origin: string): Promise => + devtools.handler(new Request(`${origin}/__connection.json`)) + + it('a hostile first request never becomes the OTP-link origin; a later loopback one does', () => + withBannerSpy('h-poison', {}, async (devtools, spy) => { + // A forged non-loopback origin is not adopted and prints nothing. + await hit(devtools, 'http://evil.example.com/__h-poison') + expect(spy).not.toHaveBeenCalled() + // A later loopback origin is adopted and prints exactly one OTP link + // (the credential rides the fragment) — the reject never locked it out. + await hit(devtools, 'http://localhost:4321/__h-poison') + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost:4321/#devframe_otp=') + expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + // First-valid origin is pinned: a second loopback request doesn't move it. + await hit(devtools, 'http://127.0.0.1:9999/__h-poison') + expect(spy).toHaveBeenCalledTimes(1) + })) + + it('adopts an exactly allow-listed non-loopback origin, but rejects a near-match', () => + withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy) => { + // Prefix/suffix near-matches of the allow-list entry are never adopted. + await hit(devtools, 'https://tools.example.com.evil.com/__h-allow') + await hit(devtools, 'https://evil.tools.example.com/__h-allow') + expect(spy).not.toHaveBeenCalled() + // The exact allow-listed origin is. + await hit(devtools, 'https://tools.example.com/__h-allow') + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#') + })) + + it('an explicit origin wins over any request', () => + withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy) => { + // Pinned: the banner points at it before any request, and a forged + // request can't move it. + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') + await hit(devtools, 'http://evil.example.com/__h-pinned') + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + })) + + it('canonicalizes an adopted origin, dropping the default port', () => + withBannerSpy('h-canon', {}, async (devtools, spy) => { + await hit(devtools, 'http://localhost:80/__h-canon') + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost/#') + expect(String(spy.mock.calls[0])).not.toContain('localhost:80') + })) + it('bridge mode: without a distDir only meta + WS are served', async () => { const wsPort = await getPort({ port: 18160, host: '127.0.0.1' }) const devtools = initDevframe(defineTestDef('handler-bridge'), { base: '/__handler-bridge/', auth: false, ws: { port: wsPort } }) diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 10b40acff..47278f9e6 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -95,10 +95,12 @@ export interface InitDevframeOptions { mcp?: boolean | McpRouteOptions /** * Public origin the host app is reachable at (e.g. `http://localhost:3000`), - * or a getter for hosts that resolve it late. When omitted (or the getter - * returns a falsy value), it is derived lazily from the first request the - * handler serves — used for the auth banner's magic link and absolute dock - * URLs. + * or a getter for hosts that resolve it late. Backs the auth banner's magic + * link and absolute dock URLs. When omitted (or the getter returns a falsy + * value), it is derived from a served request — but only when that request's + * own origin is loopback or exactly matches an `allowedOrigins` entry; a raw + * inbound `Host`/URL authority and forwarded headers are never adopted. Set + * this explicitly for a non-loopback deployment (proxy, LAN, public host). */ origin?: string | (() => string) /** diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 384e6deff..76269b9f1 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe/types' import { createMcpHandler } from '@modelcontextprotocol/server' -import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { isAllowedOrigin } from 'devframe/utils/origin' import { bridgeListChanged, buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 2f930f41d..5b3fad1c0 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins import type { ContextRpcServer } from './rpc-core' import { createServer } from 'node:http' import process from 'node:process' +import { validateOriginCandidate } from 'devframe/utils/origin' import { defineHandler, H3 as H3App, toNodeHandler } from 'h3' import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo' import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' @@ -552,9 +553,11 @@ export function createInstanceShell( // listener) — derive it from the first request and let the auth banner // wait for it, unless the caller pinned one (as a string or a getter). let derivedOrigin: string | undefined + function explicitOrigin(): string | undefined { + return typeof options.origin === 'function' ? options.origin() : options.origin + } function currentOrigin(): string | undefined { - const explicit = typeof options.origin === 'function' ? options.origin() : options.origin - return explicit || derivedOrigin + return explicitOrigin() || derivedOrigin } let authHandler: DevframeAuthHandler | undefined let bannerPrinted = false @@ -602,8 +605,21 @@ export function createInstanceShell( }).catch(() => {}) } - function noteOrigin(origin: string): void { - derivedOrigin ??= origin + /** + * Consider a request-derived origin candidate for the advertised public + * origin (which backs the OTP magic link). {@link validateOriginCandidate} + * adopts only a loopback host or an exact `allowedOrigins` match, so a raw + * inbound `Host`/URL authority never redirects the credential-bearing link. + * First-valid-origin wins: an invalid candidate leaves `derivedOrigin` unset + * — printing/registering nothing — so a later valid one can still be adopted. + */ + function noteOrigin(candidate: string): void { + if (derivedOrigin === undefined && !explicitOrigin()) { + const allowed = options.allowedOrigins + const accepted = validateOriginCandidate(candidate, Array.isArray(allowed) ? allowed : undefined) + if (accepted !== undefined) + derivedOrigin = accepted + } maybePrintBanner() maybeRegister() } diff --git a/packages/devframe/src/rpc/transports/sse-server.ts b/packages/devframe/src/rpc/transports/sse-server.ts index eb941648d..52ae49d11 100644 --- a/packages/devframe/src/rpc/transports/sse-server.ts +++ b/packages/devframe/src/rpc/transports/sse-server.ts @@ -3,9 +3,9 @@ import type { RpcFunctionDefinitionAny } from '../types' import type { DevframeNodeRpcSessionMeta, DevframeRpcConnection } from './session' import type { WsOriginRegistry } from './ws-server' import { DEVFRAME_SSE_SESSION_HEADER } from 'devframe/constants' +import { isAllowedOrigin } from 'devframe/utils/origin' import { createRpcWireCodec, peekRpcWireFrame } from '../wire-codec' import { createRpcSessionMeta } from './session' -import { isAllowedOrigin } from './ws-server' export interface SseRpcTransportOptions { /** diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 68a2257f3..5af64c060 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -13,6 +13,7 @@ import { createServer as createHttpsServer } from 'node:https' import crossws from 'crossws/adapters/node' import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants' import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' +import { isAllowedOrigin } from 'devframe/utils/origin' import { createRpcWireCodec } from '../wire-codec' import { createRpcSessionMeta } from './session' @@ -226,62 +227,13 @@ function pathMatches(a: string, b: string): boolean { return strip(a) === strip(b) } -/** - * Whether `hostname` names a loopback host: `localhost` (or any `*.localhost` - * subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the - * `127.0.0.0/8` loopback block. - * - * The IPv4 case is matched **structurally** — the whole hostname must be a - * canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare - * `startsWith('127.')` prefix check would also accept an attacker-controlled - * DNS name that merely *begins* with `127.` (`127.attacker.example`, - * `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat - * the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding / - * cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps - * genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting - * those DNS names. - */ -export function isLoopbackHostname(hostname: string): boolean { - const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets - if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') - return true - return isLoopbackIPv4(h) -} - -/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */ -function isLoopbackIPv4(hostname: string): boolean { - const octets = hostname.split('.') - if (octets.length !== 4 || !octets.every(isDecimalOctet)) - return false - return Number(octets[0]) === 127 -} - -/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */ -function isDecimalOctet(part: string): boolean { - if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0')) - return false - return Number(part) <= 255 -} - -/** - * Default origin policy for a localhost dev tool: allow requests with no - * `Origin` header (native, non-browser clients), allow any loopback origin - * (so cross-port localhost dev setups keep working), and allow explicitly - * configured origins. Everything else — a real remote page in the dev's - * browser — is rejected. - */ -export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean { - if (!origin) - return true - if (allowedOrigins.includes(origin)) - return true - try { - return isLoopbackHostname(new URL(origin).hostname) - } - catch { - return false - } -} +// The loopback / origin predicates live in the dependency-free +// `devframe/utils/origin` module so consumers that only need one check (e.g. +// the instance shell's auth-link origin validation) don't import this whole +// `crossws`-carrying transport. Re-exported here to keep the historical +// `devframe/rpc/transports/ws-server` import path for `isAllowedOrigin` / +// `isLoopbackHostname` intact. +export { isAllowedOrigin, isLoopbackHostname } from 'devframe/utils/origin' function isWsOriginRegistry( value: readonly string[] | WsOriginRegistry | false | undefined, diff --git a/packages/devframe/src/utils/origin.ts b/packages/devframe/src/utils/origin.ts new file mode 100644 index 000000000..88c071c62 --- /dev/null +++ b/packages/devframe/src/utils/origin.ts @@ -0,0 +1,99 @@ +/** + * Origin and hostname predicates shared by the RPC transports (the WS upgrade, + * SSE, and MCP origin gates) and the instance shell's authentication-link + * origin validation. Kept dependency-free and runtime-agnostic so any consumer + * can pull in a single check without dragging in a transport's `crossws` + * import. + */ + +/** + * Whether `hostname` names a loopback host: `localhost` (or any `*.localhost` + * subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the + * `127.0.0.0/8` loopback block. + * + * The IPv4 case is matched **structurally** — the whole hostname must be a + * canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare + * `startsWith('127.')` prefix check would also accept an attacker-controlled + * DNS name that merely *begins* with `127.` (`127.attacker.example`, + * `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat + * the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding / + * cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps + * genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting + * those DNS names. + */ +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets + if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') + return true + return isLoopbackIPv4(h) +} + +/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */ +function isLoopbackIPv4(hostname: string): boolean { + const octets = hostname.split('.') + if (octets.length !== 4 || !octets.every(isDecimalOctet)) + return false + return Number(octets[0]) === 127 +} + +/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */ +function isDecimalOctet(part: string): boolean { + if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0')) + return false + return Number(part) <= 255 +} + +/** + * Default origin policy for a localhost dev tool: allow requests with no + * `Origin` header (native, non-browser clients), allow any loopback origin + * (so cross-port localhost dev setups keep working), and allow explicitly + * configured origins. Everything else — a real remote page in the dev's + * browser — is rejected. + */ +export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean { + if (!origin) + return true + if (allowedOrigins.includes(origin)) + return true + try { + return isLoopbackHostname(new URL(origin).hostname) + } + catch { + return false + } +} + +/** + * Decide whether a request-derived origin candidate may back a devframe's + * advertised public origin — the destination of the OTP magic link. Stricter + * than {@link isAllowedOrigin}: it rejects credentials, a path, a query, a + * fragment, a malformed port, and non-HTTP(S) schemes, and adopts a candidate + * only when its hostname is loopback or its canonical origin exactly matches + * an `allowedOrigins` entry (a caller with no static list passes none, so only + * loopback qualifies). Returns the canonical origin to adopt, or `undefined` + * to reject. Forwarded headers are never consulted. + */ +export function validateOriginCandidate( + candidate: string, + allowedOrigins?: readonly string[], +): string | undefined { + let url: URL + try { + url = new URL(candidate) + } + catch { + return undefined + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return undefined + // A canonical origin has no credentials, path, query, or fragment; any of + // these means a full or poisoned URL, not a bare authority safe to advertise. + if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) + return undefined + const canonical = url.origin + if (canonical === 'null') + return undefined + if (isLoopbackHostname(url.hostname) || allowedOrigins?.includes(canonical)) + return canonical + return undefined +} diff --git a/packages/devframe/test/runtime-agnostic.test.ts b/packages/devframe/test/runtime-agnostic.test.ts index c7eb141ac..46e42ea47 100644 --- a/packages/devframe/test/runtime-agnostic.test.ts +++ b/packages/devframe/test/runtime-agnostic.test.ts @@ -14,6 +14,7 @@ const AGNOSTIC_ENTRIES = [ 'utils/events.mjs', 'utils/hash.mjs', 'utils/nanoid.mjs', + 'utils/origin.mjs', 'utils/shared-state.mjs', 'utils/streaming-channel.mjs', 'utils/structured-clone.mjs', diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 5ee2389a6..8adba2877 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -78,6 +78,7 @@ const clientEntries = { 'utils/events': 'src/utils/events.ts', 'utils/hash': 'src/utils/hash.ts', 'utils/nanoid': 'src/utils/nanoid.ts', + 'utils/origin': 'src/utils/origin.ts', 'utils/simple-schema': 'src/utils/simple-schema.ts', 'utils/shared-state': 'src/utils/shared-state.ts', 'utils/streaming-channel': 'src/utils/streaming-channel.ts', @@ -159,6 +160,7 @@ export default defineConfig([ resolve(distDir, 'utils/events.mjs'), resolve(distDir, 'utils/hash.mjs'), resolve(distDir, 'utils/nanoid.mjs'), + resolve(distDir, 'utils/origin.mjs'), resolve(distDir, 'utils/simple-schema.mjs'), resolve(distDir, 'utils/shared-state.mjs'), resolve(distDir, 'utils/streaming-channel.mjs'), diff --git a/plans/README.md b/plans/README.md index 16c194235..3def85aec 100644 --- a/plans/README.md +++ b/plans/README.md @@ -11,7 +11,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th | 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | TODO | | 004 | Contain remote asset materialization | P1 | S | - | DONE | | 005 | Block Data Inspector prototype-chain writes | P1 | S | - | DONE | -| 006 | Validate request-derived authentication-link origins | P1 | M | - | TODO | +| 006 | Validate request-derived authentication-link origins | P1 | M | - | DONE | | 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | DONE | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with reason) | REJECTED (with rationale) diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js index 49c4c3243..f096c4598 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js @@ -1,10 +1,13 @@ /** * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-server` */ +// #region Functions +export function attachWsRpcTransport(_, _) {} +export function createWsOriginRegistry(_) {} +export function createWsRpcPeerHooks(_, _) {} +// #endregion + // #region Other -export { attachWsRpcTransport } -export { createWsOriginRegistry } -export { createWsRpcPeerHooks } export { isAllowedOrigin } export { isLoopbackHostname } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts new file mode 100644 index 000000000..24f8556d1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/origin` + */ +// #region Other +export { isAllowedOrigin } +export { isLoopbackHostname } +export { validateOriginCandidate } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js new file mode 100644 index 000000000..aa340eb89 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/origin` + */ +// #region Functions +export function isAllowedOrigin(_, _) {} +export function isLoopbackHostname(_) {} +export function validateOriginCandidate(_, _) {} +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 6e2a0908f..162245f5f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -85,6 +85,9 @@ "devframe/utils/open": [ "./packages/devframe/src/utils/open.ts" ], + "devframe/utils/origin": [ + "./packages/devframe/src/utils/origin.ts" + ], "devframe/utils/remote-assets": [ "./packages/devframe/src/utils/remote-assets.ts" ],