From 30713329938fa2bd32c1de0914266412782d419e Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 28 Feb 2024 20:47:12 +0100 Subject: [PATCH] fix: enforce origin isolation on subdomain gws (#60) * fix: enforce origin isolation on subdomain gws Towards #30 * chore: undo ts fixup * refactor: apply suggestions from code review Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> * fix: config page redirect * chore: empty out redirects * chore: config page check supports hash routing * chore: html title * Revert "chore: empty out redirects" This reverts commit 1a6d25c9ec358c8d61605471666a73a703481c6e. * Revert "fix: config page redirect" This reverts commit 2fee3ce9963b868257068956534c8432bbe2aedf. * fix: redirects file doesnt bork config requests --------- Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> --- public/_redirects | 1 + src/app.tsx | 5 +- src/components/config.tsx | 1 + src/context/config-context.tsx | 3 +- src/index.tsx | 3 +- src/lib/is-config-page.ts | 5 ++ src/lib/path-or-subdomain.ts | 82 ++++++++++++++++++++++++++++++--- src/sw.ts | 14 ++++++ tests/path-or-subdomain.spec.ts | 16 +++---- webpack.config.js | 6 ++- 10 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 src/lib/is-config-page.ts diff --git a/public/_redirects b/public/_redirects index 3b2133ec..e31815fc 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1 +1,2 @@ +/config /#/config 302 /* /?helia-sw=/:splat 302 diff --git a/src/app.tsx b/src/app.tsx index c681dfa4..99046c9c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,15 +2,14 @@ import React, { useContext } from 'react' import Config from './components/config.tsx' import { ConfigContext } from './context/config-context.tsx' import HelperUi from './helper-ui.tsx' +import { isConfigPage } from './lib/is-config-page.ts' import { isPathOrSubdomainRequest } from './lib/path-or-subdomain.ts' import RedirectPage from './redirectPage.tsx' function App (): JSX.Element { const { isConfigExpanded, setConfigExpanded } = useContext(ConfigContext) - if (window.location.pathname === '/config') { + if (isConfigPage()) { setConfigExpanded(true) - } - if (window.location.pathname === '/config') { return } 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/context/config-context.tsx b/src/context/config-context.tsx index 66a57b12..81617063 100644 --- a/src/context/config-context.tsx +++ b/src/context/config-context.tsx @@ -1,4 +1,5 @@ import React, { createContext, useState } from 'react' +import { isConfigPage } from '../lib/is-config-page.ts' const isLoadedInIframe = window.self !== window.top @@ -9,7 +10,7 @@ export const ConfigContext = createContext({ export const ConfigProvider = ({ children, expanded = isLoadedInIframe }: { children: JSX.Element[] | JSX.Element, expanded?: boolean }): JSX.Element => { const [isConfigExpanded, setConfigExpanded] = useState(expanded) - const isExplicitlyLoadedConfigPage = window.location.pathname === '/config' + const isExplicitlyLoadedConfigPage = isConfigPage() const setConfigExpandedWrapped = (value: boolean): void => { if (isLoadedInIframe || isExplicitlyLoadedConfigPage) { diff --git a/src/index.tsx b/src/index.tsx index 58229bd1..fc14ec36 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ import App from './app.tsx' import { ConfigProvider } from './context/config-context.tsx' import { ServiceWorkerProvider } from './context/service-worker-context.tsx' import { loadConfigFromLocalStorage } from './lib/config-db.ts' +import { isConfigPage } from './lib/is-config-page.ts' await loadConfigFromLocalStorage() @@ -16,7 +17,7 @@ const root = ReactDOMClient.createRoot(container) root.render( - + diff --git a/src/lib/is-config-page.ts b/src/lib/is-config-page.ts new file mode 100644 index 00000000..2f31ab6e --- /dev/null +++ b/src/lib/is-config-page.ts @@ -0,0 +1,5 @@ +export function isConfigPage (): boolean { + const isConfigPathname = window.location.pathname === '/config' + const isConfigHashPath = window.location.hash === '#/config' // needed for _redirects and IPFS hosted sw gateways + return isConfigPathname || isConfigHashPath +} 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/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..2ab6cb75 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'] }, @@ -170,7 +174,7 @@ const common = { // Generates an HTML file from a template // Generates deprecation warning: https://github.com/jantimon/html-webpack-plugin/issues/1501 new HtmlWebpackPlugin({ - title: 'Helia bundle by Webpack', + title: 'Helia service worker gateway', favicon: paths.public + '/favicon.ico', template: paths.public + '/index.html', // template file filename: 'index.html', // output file,