Skip to content

Commit 3529c40

Browse files
committed
feat: proper /ipns/ validation
This commit removes false-positive redirects for paths that start with /ipns/{ipnsRoot} by following these steps: 1. is-ipfs test (may produce false-positives) 2. remove false-positives by checking if ipnsRoot is: - a valid CID (we check this first as its faster/cheaper) - or FQDN with a valid dnslin in DNS TXT record (expensive, but we reuse caching mechanism from dnslink experiment) This means we now _automagically_ detect valid IPFS resources on any website as long as path starts with /ipfs/ or /ipns/, removing problems described in #16 (comment) This commit also closes #69 -- initial load is suspended until dnslink is read via API, then it is cached so that all subsequent requests are very fast.
1 parent a04c6d4 commit 3529c40

File tree

3 files changed

+123
-76
lines changed

3 files changed

+123
-76
lines changed

add-on/manifest.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": 2,
33
"name": "IPFS Companion",
44
"short_name": "IPFS Companion",
5-
"version" : "2.0.10",
5+
"version" : "2.0.11",
66

77
"description": "Browser extension that simplifies access to IPFS resources",
88
"homepage_url": "https://github.com/ipfs/ipfs-companion",
@@ -59,8 +59,8 @@
5959

6060
"protocol_handlers": [
6161
{
62-
"protocol": "web+fs",
63-
"name": "IPFS Add-On: *FS protocol handler",
62+
"protocol": "web+dweb",
63+
"name": "IPFS Add-On: DWEB protocol handler",
6464
"uriTemplate": "https://ipfs.io/%s"
6565
},
6666
{

add-on/src/lib/common.js

+79-69
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function initIpfsApi (ipfsApiUrl) {
2828
return window.IpfsApi({host: url.hostname, port: url.port, procotol: url.protocol})
2929
}
3030

31-
async function initStates (options) {
31+
function initStates (options) {
3232
state.redirect = options.useCustomGateway
3333
state.apiURL = new URL(options.ipfsApiUrl)
3434
state.apiURLString = state.apiURL.toString()
@@ -54,8 +54,30 @@ function registerListeners () {
5454
// REDIRECT
5555
// ===================================================================
5656

57-
function publicIpfsResource (url) {
58-
return window.IsIpfs.url(url) && !url.startsWith(state.gwURLString) && !url.startsWith(state.apiURLString)
57+
function publicIpfsOrIpnsResource (url) {
58+
// first, exclude gateway and api, otherwise we have infinite loop
59+
if (!url.startsWith(state.gwURLString) && !url.startsWith(state.apiURLString)) {
60+
// /ipfs/ is easy to validate, we just check if CID is correct and return if true
61+
if (window.IsIpfs.ipfsUrl(url)) {
62+
return true
63+
}
64+
// /ipns/ requires multiple stages/branches, as it can be FQDN with dnslink or CID
65+
if (window.IsIpfs.ipnsUrl(url)) {
66+
const ipnsRoot = new URL(url).pathname.match(/^\/ipns\/([^/]+)/)[1]
67+
// console.log('=====> IPNS root', ipnsRoot)
68+
// first check if root is a regular CID
69+
if (window.IsIpfs.cid(ipnsRoot)) {
70+
// console.log('=====> IPNS is a valid CID', ipnsRoot)
71+
return true
72+
}
73+
if (isDnslookupSafe(url) && cachedDnslinkLookup(ipnsRoot)) {
74+
// console.log('=====> IPNS for FQDN with valid dnslink: ', ipnsRoot)
75+
return true
76+
}
77+
}
78+
}
79+
// everything else is not ipfs-related
80+
return false
5981
}
6082

6183
function redirectToCustomGateway (requestUrl) {
@@ -107,13 +129,13 @@ function onBeforeRequest (request) {
107129

108130
// handle redirects to custom gateway
109131
if (state.redirect) {
110-
// IPFS resources
111-
if (publicIpfsResource(request.url)) {
132+
// Detect valid /ipfs/ and /ipns/ on any site
133+
if (publicIpfsOrIpnsResource(request.url)) {
112134
return redirectToCustomGateway(request.url)
113135
}
114136
// Look for dnslink in TXT records of visited sites
115-
if (isDnslookupEnabled(request)) {
116-
return dnslinkLookup(request)
137+
if (state.dnslink && isDnslookupSafe(request.url)) {
138+
return dnslinkLookupAndOptionalRedirect(request.url)
117139
}
118140
}
119141
}
@@ -174,54 +196,43 @@ function normalizedUnhandledIpfsProtocol (request) {
174196
// DNSLINK
175197
// ===================================================================
176198

177-
function isDnslookupEnabled (request) {
178-
return state.dnslink &&
179-
state.peerCount > 0 &&
180-
request.url.startsWith('http') &&
181-
!request.url.startsWith(state.apiURLString) &&
182-
!request.url.startsWith(state.gwURLString)
199+
function isDnslookupSafe (requestUrl) {
200+
return state.peerCount > 0 &&
201+
requestUrl.startsWith('http') &&
202+
!requestUrl.startsWith(state.apiURLString) &&
203+
!requestUrl.startsWith(state.gwURLString)
183204
}
184205

185-
function dnslinkLookup (request) {
186-
// TODO: benchmark and improve performance
187-
const requestUrl = new URL(request.url)
188-
const fqdn = requestUrl.hostname
189-
let dnslink = state.dnslinkCache.get(fqdn)
190-
if (typeof dnslink === 'undefined') {
191-
// fetching fresh dnslink is expensive, so we switch to async
192-
console.log('dnslink cache miss for: ' + fqdn)
193-
/* According to https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest/onBeforeRequest
194-
* "From Firefox 52 onwards, instead of returning BlockingResponse, the listener can return a Promise
195-
* which is resolved with a BlockingResponse. This enables the listener to process the request asynchronously."
196-
*
197-
* Seems that this does not work yet, and even tho promise is executed, request is not blocked but resolves to regular URL.
198-
* TODO: This should be revisited after Firefox 52 is released. If does not work by then, we need to fill a bug.
199-
*/
200-
return asyncDnslookupResponse(fqdn, requestUrl)
201-
}
206+
function dnslinkLookupAndOptionalRedirect (requestUrl) {
207+
const url = new URL(requestUrl)
208+
const fqdn = url.hostname
209+
const dnslink = cachedDnslinkLookup(fqdn)
202210
if (dnslink) {
203-
console.log('SYNC resolving to Cached dnslink redirect:' + fqdn)
204-
return redirectToDnslinkPath(requestUrl, dnslink)
211+
return redirectToDnslinkPath(url, dnslink)
205212
}
206213
}
207214

208-
async function asyncDnslookupResponse (fqdn, requestUrl) {
209-
try {
210-
const dnslink = await readDnslinkTxtRecordFromApi(fqdn)
211-
if (dnslink) {
212-
state.dnslinkCache.set(fqdn, dnslink)
213-
console.log('ASYNC Resolved dnslink for:' + fqdn + ' is: ' + dnslink)
214-
return redirectToDnslinkPath(requestUrl, dnslink)
215-
} else {
216-
state.dnslinkCache.set(fqdn, false)
217-
console.log('ASYNC NO dnslink for:' + fqdn)
218-
return {}
215+
function cachedDnslinkLookup (fqdn) {
216+
let dnslink = state.dnslinkCache.get(fqdn)
217+
if (typeof dnslink === 'undefined') {
218+
try {
219+
console.log('dnslink cache miss for: ' + fqdn)
220+
dnslink = readDnslinkFromTxtRecord(fqdn)
221+
if (dnslink) {
222+
state.dnslinkCache.set(fqdn, dnslink)
223+
console.log(`Resolved dnslink: '${fqdn}' -> '${dnslink}'`)
224+
} else {
225+
state.dnslinkCache.set(fqdn, false)
226+
console.log(`Resolved NO dnslink for '${fqdn}'`)
227+
}
228+
} catch (error) {
229+
console.error(`Error in dnslinkLookupAndOptionalRedirect for '${fqdn}'`)
230+
console.error(error)
219231
}
220-
} catch (error) {
221-
console.error(`ASYNC Error in asyncDnslookupResponse for '${fqdn}': ${error}`)
222-
console.error(error)
223-
return {}
232+
} else {
233+
console.log(`Resolved via cached dnslink: '${fqdn}' -> '${dnslink}'`)
224234
}
235+
return dnslink
225236
}
226237

227238
function redirectToDnslinkPath (url, dnslink) {
@@ -232,31 +243,30 @@ function redirectToDnslinkPath (url, dnslink) {
232243
return { redirectUrl: url.toString() }
233244
}
234245

235-
function readDnslinkTxtRecordFromApi (fqdn) {
246+
function readDnslinkFromTxtRecord (fqdn) {
236247
// js-ipfs-api does not provide method for fetching this
237248
// TODO: revisit after https://github.com/ipfs/js-ipfs-api/issues/501 is addressed
238-
return new Promise((resolve, reject) => {
239-
const apiCall = state.apiURLString + '/api/v0/dns/' + fqdn
240-
const xhr = new XMLHttpRequest() // older XHR API us used because window.fetch appends Origin which causes error 403 in go-ipfs
241-
xhr.open('GET', apiCall)
242-
xhr.setRequestHeader('Accept', 'application/json')
243-
xhr.onload = function () {
244-
if (this.status === 200) {
245-
const dnslink = JSON.parse(xhr.responseText).Path
246-
resolve(dnslink)
247-
} else if (this.status === 500) {
248-
// go-ipfs returns 500 if host has no dnslink
249-
// TODO: find/fill an upstream bug to make this more intuitive
250-
resolve(false)
251-
} else {
252-
reject(new Error(xhr.statusText))
253-
}
254-
}
255-
xhr.onerror = function () {
256-
reject(new Error(xhr.statusText))
249+
const apiCall = state.apiURLString + '/api/v0/dns/' + fqdn
250+
const xhr = new XMLHttpRequest() // older XHR API us used because window.fetch appends Origin which causes error 403 in go-ipfs
251+
// synchronous mode with small timeout
252+
// (it is okay, because we do it only once, then it is cached and read via cachedDnslinkLookup)
253+
xhr.open('GET', apiCall, false)
254+
xhr.setRequestHeader('Accept', 'application/json')
255+
xhr.send(null)
256+
if (xhr.status === 200) {
257+
const dnslink = JSON.parse(xhr.responseText).Path
258+
// console.log('readDnslinkFromTxtRecord', readDnslinkFromTxtRecord)
259+
if (!window.IsIpfs.path(dnslink)) {
260+
throw new Error(`dnslink for '${fqdn}' is not a valid IPFS path: '${dnslink}'`)
257261
}
258-
xhr.send()
259-
})
262+
return dnslink
263+
} else if (xhr.status === 500) {
264+
// go-ipfs returns 500 if host has no dnslink
265+
// TODO: find/fill an upstream bug to make this more intuitive
266+
return false
267+
} else {
268+
throw new Error(xhr.statusText)
269+
}
260270
}
261271

262272
// RUNTIME MESSAGES (one-off messaging)

test/unit/01-onBeforeRequest.test.js

+41-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22
/* eslint-env webextensions, mocha */
3-
/* globals sinon, optionDefaults, should, state, onBeforeRequest */
3+
// eslint-disable-next-line no-unused-vars
4+
/* globals sinon, initStates, optionDefaults, should, state, onBeforeRequest, readDnslinkFromTxtRecord */
45

56
var sandbox
67

@@ -13,7 +14,11 @@ describe('onBeforeRequest', function () {
1314
browser.flush()
1415
sandbox = sinon.sandbox.create()
1516
browser.storage.local.get.returns(Promise.resolve(optionDefaults))
17+
// reset states
18+
initStates(optionDefaults)
19+
// stub default state for most of tests
1620
// redirect by default -- makes test code shorter
21+
state.peerCount = 1
1722
state.redirect = true
1823
state.catchUnhandledProtocols = true
1924
state.gwURLString = 'http://127.0.0.1:8080'
@@ -34,18 +39,50 @@ describe('onBeforeRequest', function () {
3439
const request = url2request('https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest')
3540
should.not.exist(onBeforeRequest(request))
3641
})
42+
it('should be left untouched if CID is invalid', function () {
43+
const request = url2request('https://ipfs.io/ipfs/notacid?argTest#hashTest')
44+
should.not.exist(onBeforeRequest(request))
45+
})
3746
})
3847

3948
describe('request for a path matching /ipns/{path}', function () {
40-
it('should be served from custom gateway if redirect is enabled', function () {
41-
const request = url2request('https://ipfs.io/ipns/ipfs.io/index.html?argTest#hashTest')
42-
onBeforeRequest(request).redirectUrl.should.equal('http://127.0.0.1:8080/ipns/ipfs.io/index.html?argTest#hashTest')
49+
it('should be served from custom gateway if {path} points to a FQDN with existing dnslink', function () {
50+
const request = url2request('https://ipfs.io/ipns/ipfs.git.sexy/index.html?argTest#hashTest')
51+
// stub the existence of valid dnslink
52+
const fqdn = 'ipfs.git.sexy'
53+
// eslint-disable-next-line no-global-assign
54+
readDnslinkFromTxtRecord = sandbox.stub().withArgs(fqdn).returns('/ipfs/Qmazvovg6Sic3m9igZMKoAPjkiVZsvbWWc8ZvgjjK1qMss')
55+
// pretend API is online and we can do dns lookups with it
56+
state.peerCount = 1
57+
onBeforeRequest(request).redirectUrl.should.equal('http://127.0.0.1:8080/ipns/ipfs.git.sexy/index.html?argTest#hashTest')
58+
})
59+
it('should be served from custom gateway if {path} starts with a valid CID', function () {
60+
const request = url2request('https://ipfs.io/ipns/QmSWnBwMKZ28tcgMFdihD8XS7p6QzdRSGf71cCybaETSsU/index.html?argTest#hashTest')
61+
// eslint-disable-next-line no-global-assign
62+
readDnslinkFromTxtRecord = sandbox.stub().returns(false)
63+
onBeforeRequest(request).redirectUrl.should.equal('http://127.0.0.1:8080/ipns/QmSWnBwMKZ28tcgMFdihD8XS7p6QzdRSGf71cCybaETSsU/index.html?argTest#hashTest')
4364
})
4465
it('should be left untouched if redirect is disabled', function () {
4566
state.redirect = false
4667
const request = url2request('https://ipfs.io/ipns/ipfs.io?argTest#hashTest')
4768
should.not.exist(onBeforeRequest(request))
4869
})
70+
it('should be left untouched if FQDN is not a real domain nor a valid CID', function () {
71+
const request = url2request('https://ipfs.io/ipns/notafqdnorcid?argTest#hashTest')
72+
// eslint-disable-next-line no-global-assign
73+
readDnslinkFromTxtRecord = sandbox.stub().returns(false)
74+
should.not.exist(onBeforeRequest(request))
75+
})
76+
it('should be left untouched if {path} points to a FQDN but API is offline', function () {
77+
const request = url2request('https://ipfs.io/ipns/ipfs.git.sexy/index.html?argTest#hashTest')
78+
// stub the existence of valid dnslink in dnslink cache
79+
const fqdn = 'ipfs.git.sexy'
80+
// eslint-disable-next-line no-global-assign
81+
readDnslinkFromTxtRecord = sandbox.stub().withArgs(fqdn).returns('/ipfs/Qmazvovg6Sic3m9igZMKoAPjkiVZsvbWWc8ZvgjjK1qMss')
82+
// pretend API is offline and we can do dns lookups with it
83+
state.peerCount = 0
84+
should.not.exist(onBeforeRequest(request))
85+
})
4986
})
5087

5188
describe('request made via "web+" handler from manifest.json/protocol_handlers', function () {

0 commit comments

Comments
 (0)