-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: enforce origin isolation on subdomain gws #60
Changes from all commits
1b09048
84c44a9
5767cab
2fee3ce
1a6d25c
59aca7e
928a4ac
4f7dbe5
2d30f35
be5c11d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
/config /#/config 302 | ||
/* /?helia-sw=/:splat 302 |
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 | ||
} |
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> => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. its worth adding a comment here saying that for our SW usecase, we're only worried about checking for a 200 response and no TLS error. |
||
// 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 | ||
} | ||
} | ||
Comment on lines
+38
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 this is not a big deal because it only happens once when on /ipfs/cid paths, is not executed when on subdomain host, but perhaps we could leverage config passing setup based on iframe introduced in #24 |
||
|
||
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why are we There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid CIDv1 in base32 will be also a valid DNS name. If we did it the other way, ISP or DNS operator could return spoofed DNS name that is also a valid CID, and do MITM on users who only use cryptographic identifies. |
||
// /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 | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@SgtPooki is this something we want to keep, or can it be removed?
iirc if we don't have query param, we would reuse cache, making subdomain page loads faster
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is only for loading config in iframe. Iframe needs to know parent origin to postmessage.
Target origin (main window) is subdomain, iframe is root domain.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So if we can do that another way, we could remove this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use
#hash
instead? It does not get sent to server, but JS in inframe should see it.