Skip to content

Commit

Permalink
fix: enforce origin isolation on subdomain gws (#60)
Browse files Browse the repository at this point in the history
* 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 1a6d25c.

* Revert "fix: config page redirect"

This reverts commit 2fee3ce.

* fix: redirects file doesnt bork config requests

---------

Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com>
  • Loading branch information
lidel and SgtPooki authored Feb 28, 2024
1 parent ed2de44 commit 3071332
Show file tree
Hide file tree
Showing 10 changed files with 116 additions and 20 deletions.
1 change: 1 addition & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/config /#/config 302
/* /?helia-sw=/:splat 302
5 changes: 2 additions & 3 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Config />
}

Expand Down
1 change: 1 addition & 0 deletions src/components/config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 2 additions & 1 deletion src/context/config-context.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useState } from 'react'
import { isConfigPage } from '../lib/is-config-page.ts'

const isLoadedInIframe = window.self !== window.top

Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -16,7 +17,7 @@ const root = ReactDOMClient.createRoot(container)
root.render(
<React.StrictMode>
<ServiceWorkerProvider>
<ConfigProvider expanded={window.location.pathname === '/config'}>
<ConfigProvider expanded={isConfigPage()}>
<App />
</ConfigProvider>
</ServiceWorkerProvider>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/is-config-page.ts
Original file line number Diff line number Diff line change
@@ -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
}
82 changes: 76 additions & 6 deletions src/lib/path-or-subdomain.ts
Original file line number Diff line number Diff line change
@@ -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 = /^(?<id>[^/]+)\.(?<protocol>ip[fn]s)\.[^/]+$/
const pathRegex = /^\/(?<protocol>ip[fn]s)\/(?<path>.*)$/

export const isPathOrSubdomainRequest = (location: Pick<Location, 'hostname' | 'pathname'>): boolean => {
const subdomain = location.hostname
const subdomainMatch = subdomain.match(subdomainRegex)
export const isPathOrSubdomainRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
return isPathGatewayRequest(location) || isSubdomainGatewayRequest(location)
}

export const isSubdomainGatewayRequest = (location: Pick<Location, 'host' | 'pathname'>): boolean => {
const subdomainMatch = location.host.match(subdomainRegex)
return subdomainMatch?.groups != null
}

export const isPathGatewayRequest = (location: Pick<Location, 'host' | 'pathname'>): 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<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash' >): Promise<string | null> => {
if (isPathGatewayRequest(location) && !isSubdomainGatewayRequest(location)) {
const redirect = await isSubdomainIsolationSupported(location)
if (redirect) {
return toSubdomainRequest(location)
}
}
return null
}

const isSubdomainIsolationSupported = async (location: Pick<Location, 'protocol' | 'host' | 'pathname'>): Promise<boolean> => {
// 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<Location, 'protocol' | 'host' | 'pathname' | 'search' | 'hash'>): 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
}
14 changes: 14 additions & 0 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,19 @@ interface FetchHandlerArg {
}

const fetchHandler = async ({ path, request }: FetchHandlerArg): Promise<Response> => {
// 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()
}
Expand Down
16 changes: 8 additions & 8 deletions tests/path-or-subdomain.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
6 changes: 5 additions & 1 deletion webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']
},

Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 3071332

Please sign in to comment.