diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/file-stream.js | 3 | ||||
-rw-r--r-- | lib/file.js | 26 | ||||
-rw-r--r-- | lib/rarity-map.js | 2 | ||||
-rw-r--r-- | lib/server.js | 74 | ||||
-rw-r--r-- | lib/torrent.js | 81 | ||||
-rw-r--r-- | lib/webconn.js | 1 |
6 files changed, 131 insertions, 56 deletions
diff --git a/lib/file-stream.js b/lib/file-stream.js index 72622b2..f6a8402 100644 --- a/lib/file-stream.js +++ b/lib/file-stream.js @@ -56,9 +56,10 @@ class FileStream extends stream.Readable { this._torrent.store.get(p, (err, buffer) => { this._notifying = false if (this.destroyed) return - if (err) return this._destroy(err) debug('read %s (length %s) (err %s)', p, buffer.length, err && err.message) + if (err) return this._destroy(err) + if (this._offset) { buffer = buffer.slice(this._offset) this._offset = 0 diff --git a/lib/file.js b/lib/file.js index 8bcfe22..3e75091 100644 --- a/lib/file.js +++ b/lib/file.js @@ -41,10 +41,12 @@ class File extends EventEmitter { const { _startPiece: start, _endPiece: end } = this const piece = pieces[start] - // Calculate first piece diffrently, it sometimes have a offset + // First piece may have an offset, e.g. irrelevant bytes from the end of + // the previous file + const irrelevantFirstPieceBytes = this.offset % pieceLength let downloaded = bitfield.get(start) - ? pieceLength - (this.offset % pieceLength) - : Math.max(piece.length - piece.missing - this.offset, 0) + ? pieceLength - irrelevantFirstPieceBytes + : Math.max(pieceLength - irrelevantFirstPieceBytes - piece.missing, 0) for (let index = start + 1; index <= end; ++index) { if (bitfield.get(index)) { @@ -53,12 +55,12 @@ class File extends EventEmitter { } else { // "in progress" data const piece = pieces[index] - downloaded += piece.length - piece.missing + downloaded += pieceLength - piece.missing } } - // We don't have a end-offset and one small file can fith in the middle - // of one chunk, so return this.length if it's oversized + // We don't know the end offset, so return this.length if it's oversized. + // e.g. One small file can fit in the middle of a piece. return Math.min(downloaded, this.length) } @@ -104,12 +106,20 @@ class File extends EventEmitter { getBlob (cb) { if (typeof window === 'undefined') throw new Error('browser-only method') - streamToBlob(this.createReadStream(), this._getMimeType(), cb) + streamToBlob(this.createReadStream(), this._getMimeType()) + .then( + blob => cb(null, blob), + err => cb(err) + ) } getBlobURL (cb) { if (typeof window === 'undefined') throw new Error('browser-only method') - streamToBlobURL(this.createReadStream(), this._getMimeType(), cb) + streamToBlobURL(this.createReadStream(), this._getMimeType()) + .then( + blobUrl => cb(null, blobUrl), + err => cb(err) + ) } appendTo (elem, opts, cb) { diff --git a/lib/rarity-map.js b/lib/rarity-map.js index 7630734..af712cc 100644 --- a/lib/rarity-map.js +++ b/lib/rarity-map.js @@ -45,7 +45,7 @@ class RarityMap { if (availability === min) { candidates.push(i) } else if (availability < min) { - candidates = [ i ] + candidates = [i] min = availability } } diff --git a/lib/server.js b/lib/server.js index 953d6a1..b2e307d 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,9 +1,9 @@ const arrayRemove = require('unordered-array-remove') +const escapeHtml = require('escape-html') const http = require('http') const mime = require('mime') const pump = require('pump') const rangeParser = require('range-parser') -const url = require('url') function Server (torrent, opts = {}) { const server = http.createServer() @@ -53,13 +53,6 @@ function Server (torrent, opts = {}) { // deny them if (req.headers.origin == null) return false - // If a 'hostname' string is specified, deny requests with a 'Host' - // header that does not match the origin of the torrent server to prevent - // DNS rebinding attacks. - if (opts.hostname && req.headers.host !== `${opts.hostname}:${server.address().port}`) { - return false - } - // The user allowed all origins if (opts.origin === '*') return true @@ -76,12 +69,15 @@ function Server (torrent, opts = {}) { } function onRequest (req, res) { - const pathname = url.parse(req.url).pathname - - if (pathname === '/favicon.ico') { - return serve404Page() + // If a 'hostname' string is specified, deny requests with a 'Host' + // header that does not match the origin of the torrent server to prevent + // DNS rebinding attacks. + if (opts.hostname && req.headers.host !== `${opts.hostname}:${server.address().port}`) { + return req.destroy() } + const pathname = new URL(req.url, 'http://example.com').pathname + // Allow cross-origin requests (CORS) if (isOriginAllowed(req)) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin) @@ -90,6 +86,13 @@ function Server (torrent, opts = {}) { // Prevent browser mime-type sniffing res.setHeader('X-Content-Type-Options', 'nosniff') + // Defense-in-depth: Set a strict Content Security Policy to mitigate XSS + res.setHeader('Content-Security-Policy', "base-uri 'none'; default-src 'none'; frame-ancestors 'none'; form-action 'none';") + + if (pathname === '/favicon.ico') { + return serve404Page() + } + // Allow CORS requests to specify arbitrary headers, e.g. 'Range', // by responding to the OPTIONS preflight request with the specified // origin and requested headers. @@ -147,11 +150,26 @@ function Server (torrent, opts = {}) { res.statusCode = 200 res.setHeader('Content-Type', 'text/html') - const listHtml = torrent.files.map((file, i) => `<li><a download="${file.name}" href="/${i}/${file.name}">${file.path}</a> (${file.length} bytes)</li>`).join('<br>') + const listHtml = torrent.files + .map((file, i) => ( + `<li> + <a + download="${escapeHtml(file.name)}" + href="${escapeHtml(i)}/${escapeHtml(file.name)}" + > + ${escapeHtml(file.path)} + </a> + (${escapeHtml(file.length)} bytes) + </li>` + )) + .join('<br>') const html = getPageHTML( - `${torrent.name} - WebTorrent`, - `<h1>${torrent.name}</h1><ol>${listHtml}</ol>` + `${escapeHtml(torrent.name)} - WebTorrent`, + ` + <h1>${escapeHtml(torrent.name)}</h1> + <ol>${listHtml}</ol> + ` ) res.end(html) } @@ -160,13 +178,16 @@ function Server (torrent, opts = {}) { res.statusCode = 404 res.setHeader('Content-Type', 'text/html') - const html = getPageHTML('404 - Not Found', '<h1>404 - Not Found</h1>') + const html = getPageHTML( + '404 - Not Found', + '<h1>404 - Not Found</h1>' + ) res.end(html) } function serveFile (file) { res.statusCode = 200 - res.setHeader('Content-Type', mime.getType(file.name)) + res.setHeader('Content-Type', mime.getType(file.name) || 'application/octet-stream') // Support range-requests res.setHeader('Accept-Ranges', 'bytes') @@ -214,7 +235,10 @@ function Server (torrent, opts = {}) { function serveMethodNotAllowed () { res.statusCode = 405 res.setHeader('Content-Type', 'text/html') - const html = getPageHTML('405 - Method Not Allowed', '<h1>405 - Method Not Allowed</h1>') + const html = getPageHTML( + '405 - Method Not Allowed', + '<h1>405 - Method Not Allowed</h1>' + ) res.end(html) } } @@ -222,8 +246,20 @@ function Server (torrent, opts = {}) { return server } +// NOTE: Arguments must already be HTML-escaped function getPageHTML (title, pageHtml) { - return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body>${pageHtml}</body></html>` + return ` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="utf-8"> + <title>${title}</title> + </head> + <body> + ${pageHtml} + </body> + </html> + ` } // From https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent diff --git a/lib/torrent.js b/lib/torrent.js index 8f667ff..77cf898 100644 --- a/lib/torrent.js +++ b/lib/torrent.js @@ -1,4 +1,4 @@ -/* global URL, Blob */ +/* global Blob */ const addrToIPPort = require('addr-to-ip-port') const BitField = require('bitfield') @@ -46,7 +46,7 @@ const RECHOKE_OPTIMISTIC_DURATION = 2 // 30 seconds // IndexedDB chunk stores used in the browser benefit from maximum concurrency const FILESYSTEM_CONCURRENCY = process.browser ? Infinity : 2 -const RECONNECT_WAIT = [ 1000, 5000, 15000 ] +const RECONNECT_WAIT = [1000, 5000, 15000] const VERSION = require('../package.json').version const USER_AGENT = `WebTorrent/${VERSION} (https://webtorrent.io)` @@ -169,7 +169,7 @@ class Torrent extends EventEmitter { if (typeof window === 'undefined') throw new Error('browser-only property') if (!this.torrentFile) return null return URL.createObjectURL( - new Blob([ this.torrentFile ], { type: 'application/x-bittorrent' }) + new Blob([this.torrentFile], { type: 'application/x-bittorrent' }) ) } @@ -277,6 +277,19 @@ class Torrent extends EventEmitter { } _onListening () { + if (this.destroyed) return + + if (this.info) { + // if full metadata was included in initial torrent id, use it immediately. Otherwise, + // wait for torrent-discovery to find peers and ut_metadata to get the metadata. + this._onMetadata(this) + } else { + if (this.xs) this._getMetadataFromServer() + this._startDiscovery() + } + } + + _startDiscovery () { if (this.discovery || this.destroyed) return let trackerOpts = this.client.tracker @@ -334,21 +347,13 @@ class Torrent extends EventEmitter { this.discovery.on('warning', (err) => { this.emit('warning', err) }) - - if (this.info) { - // if full metadata was included in initial torrent id, use it immediately. Otherwise, - // wait for torrent-discovery to find peers and ut_metadata to get the metadata. - this._onMetadata(this) - } else if (this.xs) { - this._getMetadataFromServer() - } } _getMetadataFromServer () { // to allow function hoisting const self = this - const urls = Array.isArray(this.xs) ? this.xs : [ this.xs ] + const urls = Array.isArray(this.xs) ? this.xs : [this.xs] const tasks = urls.map(url => cb => { getMetadataFromURL(url, cb) @@ -500,11 +505,23 @@ class Torrent extends EventEmitter { this._onWireWithMetadata(wire) }) + // Emit 'metadata' before 'ready' and 'done' + this.emit('metadata') + + // User might destroy torrent in response to 'metadata' event + if (this.destroyed) return + if (this.skipVerify) { // Skip verifying exisitng data and just assume it's correct this._markAllVerified() this._onStore() } else { + const onPiecesVerified = (err) => { + if (err) return this._destroy(err) + this._debug('done verifying') + this._onStore() + } + this._debug('verifying existing torrent data') if (this._fileModtimes && this._store === FSChunkStore) { // don't verify if the files haven't been modified since we last checked @@ -517,15 +534,13 @@ class Torrent extends EventEmitter { this._markAllVerified() this._onStore() } else { - this._verifyPieces() + this._verifyPieces(onPiecesVerified) } }) } else { - this._verifyPieces() + this._verifyPieces(onPiecesVerified) } } - - this.emit('metadata') } /* @@ -547,8 +562,8 @@ class Torrent extends EventEmitter { }) } - _verifyPieces () { - parallelLimit(this.pieces.map((_, index) => cb => { + _verifyPieces (cb) { + parallelLimit(this.pieces.map((piece, index) => cb => { if (this.destroyed) return cb(new Error('torrent is destroyed')) this.store.get(index, (err, buf) => { @@ -559,7 +574,7 @@ class Torrent extends EventEmitter { if (this.destroyed) return cb(new Error('torrent is destroyed')) if (hash === this._hashes[index]) { - if (!this.pieces[index]) return + if (!this.pieces[index]) return cb(null) this._debug('piece verified %s', index) this._markVerified(index) } else { @@ -568,10 +583,21 @@ class Torrent extends EventEmitter { cb(null) }) }) - }), FILESYSTEM_CONCURRENCY, err => { - if (err) return this._destroy(err) - this._debug('done verifying') - this._onStore() + }), FILESYSTEM_CONCURRENCY, cb) + } + + rescanFiles (cb) { + if (this.destroyed) throw new Error('torrent is destroyed') + if (!cb) cb = noop + + this._verifyPieces((err) => { + if (err) { + this._destroy(err) + return cb(err) + } + + this._checkDone() + cb(null) }) } @@ -594,6 +620,9 @@ class Torrent extends EventEmitter { if (this.destroyed) return this._debug('on store') + // Start discovery before emitting 'ready' + this._startDiscovery() + this.ready = true this.emit('ready') @@ -820,7 +849,7 @@ class Torrent extends EventEmitter { if (this.destroyed) throw new Error('torrent is destroyed') if (start < 0 || end < start || this.pieces.length <= end) { - throw new Error('invalid selection ', start, ':', end) + throw new Error(`invalid selection ${start} : ${end}`) } priority = Number(priority) || 0 @@ -1146,7 +1175,7 @@ class Torrent extends EventEmitter { const self = this if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { - window.requestIdleCallback(function () { self._updateWire(wire) }) + window.requestIdleCallback(function () { self._updateWire(wire) }, { timeout: 250 }) } else { self._updateWire(wire) } @@ -1564,7 +1593,7 @@ class Torrent extends EventEmitter { if (this.destroyed) throw new Error('torrent is destroyed') if (!this.ready) return this.once('ready', () => { this.load(streams, cb) }) - if (!Array.isArray(streams)) streams = [ streams ] + if (!Array.isArray(streams)) streams = [streams] if (!cb) cb = noop const readable = new MultiStream(streams) diff --git a/lib/webconn.js b/lib/webconn.js index 9c2b461..e99c04c 100644 --- a/lib/webconn.js +++ b/lib/webconn.js @@ -1,5 +1,4 @@ const BitField = require('bitfield') -const Buffer = require('safe-buffer').Buffer const debug = require('debug')('webtorrent:webconn') const get = require('simple-get') const sha1 = require('simple-sha1') |