diff --git a/src/components/config.tsx b/src/components/config.tsx index cff18b0b..9bfcfa8f 100644 --- a/src/components/config.tsx +++ b/src/components/config.tsx @@ -44,6 +44,7 @@ export default (): JSX.Element | null => { return } // we get the iframe origin from a query parameter called 'origin', if this is loaded in an iframe + // TODO: why we need this origin here? where is targetOrigin used? const targetOrigin = decodeURIComponent(window.location.search.split('origin=')[1]) const config = await getConfig() diff --git a/src/lib/heliaFetch.ts b/src/lib/heliaFetch.ts index 133581d2..5e80b576 100644 --- a/src/lib/heliaFetch.ts +++ b/src/lib/heliaFetch.ts @@ -128,6 +128,7 @@ export async function heliaFetch ({ path, helia, signal, headers, id, protocol } const verifiedFetch = await createVerifiedFetch({ gateways: [...config.gateways, 'https://trustless-gateway.link'], routers: [...config.routers, 'https://delegated-ipfs.dev'], + // @ts-expect-error dnsResolvers' does not exist in type 'Helia | CreateVerifiedFetchInit' dnsResolvers: ['https://delegated-ipfs.dev/dns-query'].map(dnsJsonOverHttps) }, { contentTypeParser diff --git a/src/lib/path-or-subdomain.ts b/src/lib/path-or-subdomain.ts index 8b868d61..d729d657 100644 --- a/src/lib/path-or-subdomain.ts +++ b/src/lib/path-or-subdomain.ts @@ -1,14 +1,84 @@ +import { base32 } from 'multiformats/bases/base32' +import { base36 } from 'multiformats/bases/base36' +import { CID } from 'multiformats/cid' +import { dnsLinkLabelEncoder } from './dns-link-labels.ts' + // TODO: dry, this is same regex code as in getSubdomainParts const subdomainRegex = /^(?[^/]+)\.(?ip[fn]s)\.[^/]+$/ const pathRegex = /^\/(?ip[fn]s)\/(?.*)$/ -export const isPathOrSubdomainRequest = (location: Pick): boolean => { - const subdomain = location.hostname - const subdomainMatch = subdomain.match(subdomainRegex) +export const isPathOrSubdomainRequest = (location: Pick): boolean => { + return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location) +} +export const isSubdomainGatewayRequest = (location: Pick): boolean => { + const subdomainMatch = location.host.match(subdomainRegex) + return subdomainMatch?.groups != null +} + +export const isPathGatewayRequest = (location: Pick): boolean => { const pathMatch = location.pathname.match(pathRegex) - const isPathBasedRequest = pathMatch?.groups != null - const isSubdomainRequest = subdomainMatch?.groups != null + return pathMatch?.groups != null +} + +/** + * Origin isolation check and enforcement + * https://github.com/ipfs-shipyard/helia-service-worker-gateway/issues/30 + */ +export const findOriginIsolationRedirect = async (location: Pick): Promise => { + if (isPathGatewayRequest(location) && !isSubdomainGatewayRequest(location)) { + const redirect = await isSubdomainIsolationSupported(location) + if (redirect) { + return toSubdomainRequest(location) + } + } + return null +} + +const isSubdomainIsolationSupported = async (location: Pick): Promise => { + // TODO: do this test once and expose it as cookie / config flag somehow + const testUrl = `${location.protocol}//bafkqaaa.ipfs.${location.host}` + try { + const response: Response = await fetch(testUrl) + return response.status === 200 + } catch (_) { + return false + } +} + +const toSubdomainRequest = (location: Pick): string => { + const segments = location.pathname.split('/').filter(segment => segment !== '') + const ns = segments[0] + let id = segments[1] - return isPathBasedRequest || isSubdomainRequest + // DNS labels are case-insensitive, and the length limit is 63. + // We ensure base32 if CID, base36 if ipns, + // or inlined according to https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header if DNSLink name + try { + switch (ns) { + case 'ipfs': + // Base32 is case-insensitive and allows CID with popular hashes like sha2-256 to fit in a single DNS label + id = CID.parse(id).toV1().toString(base32) + break + case 'ipns': + // IPNS Names are represented as Base36 CIDv1 with libp2p-key codec + // https://specs.ipfs.tech/ipns/ipns-record/#ipns-name + // eslint-disable-next-line no-case-declarations + const ipnsName = CID.parse(id).toV1() + // /ipns/ namespace uses Base36 instead of 32 because ED25519 keys need to fit in DNS label of max length 63 + id = ipnsName.toString(base36) + break + default: + throw new Error('Unknown namespace: ' + ns) + } + } catch (_) { + // not a CID, so we assume a DNSLink name and inline it according to + // https://specs.ipfs.tech/http-gateways/subdomain-gateway/#host-request-header + if (id.includes('.')) { + id = dnsLinkLabelEncoder(id) + } + } + const remainingPath = `/${segments.slice(2).join('/')}` + const newLocation = new URL(`${location.protocol}//${id}.${ns}.${location.host}${remainingPath}${location.search}${location.hash}`) + return newLocation.href } diff --git a/src/redirectPage.tsx b/src/redirectPage.tsx index 7629d37f..86e545f1 100644 --- a/src/redirectPage.tsx +++ b/src/redirectPage.tsx @@ -5,6 +5,7 @@ import { HeliaServiceWorkerCommsChannel } from './lib/channel.ts' import { setConfig, type ConfigDb } from './lib/config-db.ts' import { getSubdomainParts } from './lib/get-subdomain-parts' import { error } from './lib/logger.ts' +import { findOriginIsolationRedirect } from './lib/path-or-subdomain.ts' const ConfigIframe = (): JSX.Element => { const { parentDomain } = getSubdomainParts(window.location.href) @@ -22,6 +23,19 @@ export default function RedirectPage (): JSX.Element { const [isAutoReloadEnabled, setIsAutoReloadEnabled] = useState(false) const { isServiceWorkerRegistered } = useContext(ServiceWorkerContext) + /* TODO:: we can enable this after we have means of caching the check result + useEffect(() => { + async function originEnforcement (): Promise { + // enforce early when loaded before SW was registered + const originRedirect = await findOriginIsolationRedirect(window.location) + if (originRedirect !== null) { + window.location.replace(originRedirect) + } + } + void originEnforcement() + }, []) + */ + useEffect(() => { async function doWork (config: ConfigDb): Promise { try { diff --git a/src/sw.ts b/src/sw.ts index 7cd88ce8..8334aaf8 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -4,6 +4,7 @@ import { HeliaServiceWorkerCommsChannel, type ChannelMessage } from './lib/chann import { getSubdomainParts } from './lib/get-subdomain-parts.ts' import { heliaFetch } from './lib/heliaFetch.ts' import { error, log, trace } from './lib/logger.ts' +import { findOriginIsolationRedirect } from './lib/path-or-subdomain.ts' import type { Helia } from '@helia/interface' declare let self: ServiceWorkerGlobalScope @@ -49,6 +50,19 @@ interface FetchHandlerArg { } const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise => { + // test and enforce origin isolation before anything else is executed + const originLocation = await findOriginIsolationRedirect(new URL(request.url)) + if (originLocation !== null) { + const body = 'Gateway supports subdomain mode, redirecting to ensure Origin isolation..' + return new Response(body, { + status: 301, + headers: { + 'Content-Type': 'text/plain', + Location: originLocation + } + }) + } + if (helia == null) { helia = await getHelia() } diff --git a/tests/path-or-subdomain.spec.ts b/tests/path-or-subdomain.spec.ts index 0c26a94a..a940bbe0 100644 --- a/tests/path-or-subdomain.spec.ts +++ b/tests/path-or-subdomain.spec.ts @@ -5,44 +5,44 @@ import { isPathOrSubdomainRequest } from '../src/lib/path-or-subdomain.ts' describe('isPathOrSubdomainRequest', () => { it('returns true for path-based request', () => { expect(isPathOrSubdomainRequest({ - hostname: 'example.com', + host: 'example.com', pathname: '/ipfs/bafyFoo' })).to.equal(true) expect(isPathOrSubdomainRequest({ - hostname: 'example.com', + host: 'example.com', pathname: '/ipns/specs.ipfs.tech' })).to.equal(true) }) it('returns true for subdomain request', () => { expect(isPathOrSubdomainRequest({ - hostname: 'bafyFoo.ipfs.example.com', + host: 'bafyFoo.ipfs.example.com', pathname: '/' })).to.equal(true) expect(isPathOrSubdomainRequest({ - hostname: 'docs.ipfs.tech.ipns.example.com', + host: 'docs.ipfs.tech.ipns.example.com', pathname: '/' })).to.equal(true) }) it('returns true for inlined dnslink subdomain request', () => { expect(isPathOrSubdomainRequest({ - hostname: 'bafyFoo.ipfs.example.com', + host: 'bafyFoo.ipfs.example.com', pathname: '/' })).to.equal(true) expect(isPathOrSubdomainRequest({ - hostname: 'specs-ipfs-tech.ipns.example.com', + host: 'specs-ipfs-tech.ipns.example.com', pathname: '/' })).to.equal(true) }) it('returns false for non-path and non-subdomain request', () => { expect(isPathOrSubdomainRequest({ - hostname: 'example.com', + host: 'example.com', pathname: '/foo/bar' })).to.equal(false) expect(isPathOrSubdomainRequest({ - hostname: 'foo.bar.example.com', + host: 'foo.bar.example.com', pathname: '/' })).to.equal(false) }) diff --git a/webpack.config.js b/webpack.config.js index 74b64669..98b9a868 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -106,6 +106,10 @@ const dev = { // Only update what has changed on hot reload hot: true, port: 3000, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET' + }, allowedHosts: ['helia-sw-gateway.localhost', 'localhost'] },