From 0e1f26849bb843af0de61802d95ef13d60f447cf Mon Sep 17 00:00:00 2001 From: MICHAEL JACKSON Date: Sat, 11 Nov 2017 12:18:13 -0800 Subject: [PATCH] Add auth and blacklist APIs --- .gitignore | 3 + client/About.md | 2 +- client/App.test.js | 8 - client/Layout.js | 2 +- package.json | 6 +- public.key | 9 + scripts/add-blacklist.js | 120 +++++++++++ scripts/create-token.js | 19 ++ server/AuthAPI.js | 118 ++++++++++ server/BlacklistAPI.js | 71 +++++++ server/PackageBlacklist.json | 118 ---------- server/StatsAPI.js | 70 +++--- server/__tests__/AuthAPI-test.js | 39 ++++ server/__tests__/BlacklistAPI-test.js | 24 +++ server/__tests__/server-test.js | 201 ++++++++++++++++++ server/__tests__/utils/withBlacklist.js | 7 + server/__tests__/utils/withRevokedToken.js | 12 ++ server/__tests__/utils/withToken.js | 7 + server/actions/addToBlacklist.js | 48 +++++ server/actions/createAuth.js | 24 +++ server/actions/removeFromBlacklist.js | 42 ++++ server/actions/showAuth.js | 5 + server/actions/showBlacklist.js | 17 ++ server/actions/showPublicKey.js | 7 + server/actions/showStats.js | 62 ++++++ server/createServer.js | 48 +++-- server/createServer.test.js | 38 ---- server/createStatsServer.js | 87 -------- server/middleware/checkBlacklist.js | 33 ++- .../middleware/{packageURL.js => parseURL.js} | 26 +-- server/middleware/requireAuth.js | 40 ++++ server/middleware/userToken.js | 41 ++++ .../getFileContentType-test.js} | 2 +- .../parsePackageURL-test.js} | 14 +- yarn.lock | 135 +++++++++++- 35 files changed, 1166 insertions(+), 339 deletions(-) delete mode 100644 client/App.test.js create mode 100644 public.key create mode 100644 scripts/add-blacklist.js create mode 100644 scripts/create-token.js create mode 100644 server/AuthAPI.js create mode 100644 server/BlacklistAPI.js delete mode 100644 server/PackageBlacklist.json create mode 100644 server/__tests__/AuthAPI-test.js create mode 100644 server/__tests__/BlacklistAPI-test.js create mode 100644 server/__tests__/server-test.js create mode 100644 server/__tests__/utils/withBlacklist.js create mode 100644 server/__tests__/utils/withRevokedToken.js create mode 100644 server/__tests__/utils/withToken.js create mode 100644 server/actions/addToBlacklist.js create mode 100644 server/actions/createAuth.js create mode 100644 server/actions/removeFromBlacklist.js create mode 100644 server/actions/showAuth.js create mode 100644 server/actions/showBlacklist.js create mode 100644 server/actions/showPublicKey.js create mode 100644 server/actions/showStats.js delete mode 100644 server/createServer.test.js delete mode 100644 server/createStatsServer.js rename server/middleware/{packageURL.js => parseURL.js} (79%) create mode 100644 server/middleware/requireAuth.js create mode 100644 server/middleware/userToken.js rename server/middleware/utils/{getFileContentType.test.js => __tests__/getFileContentType-test.js} (95%) rename server/utils/{parsePackageURL.test.js => __tests__/parsePackageURL-test.js} (92%) diff --git a/.gitignore b/.gitignore index 897ba1d..1782dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ yarn-error.log* # redis dump.rdb + +# keys +private.key diff --git a/client/About.md b/client/About.md index 39360a2..d610f5d 100644 --- a/client/About.md +++ b/client/About.md @@ -33,7 +33,7 @@ unpkg is not affiliated with or supported by npm, Inc. in any way. Please do not ### Abuse -unpkg maintains [a blacklist](https://github.com/unpkg/unpkg.com/blob/master/server/PackageBlacklist.json) of packages that are known to be malicious. If you find such a package on npm, please take a moment to submit a PR that adds it to the list! +unpkg maintains of packages that are known to be malicious. If you find such a package on npm, please let us know! ### Feedback diff --git a/client/App.test.js b/client/App.test.js deleted file mode 100644 index 76d121e..0000000 --- a/client/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import App from './App' - -it('renders without crashing', () => { - const div = document.createElement('div') - ReactDOM.render(, div) -}) diff --git a/client/Layout.js b/client/Layout.js index 455eb93..407ca45 100644 --- a/client/Layout.js +++ b/client/Layout.js @@ -47,7 +47,7 @@ class Layout extends React.Component { componentDidMount() { this.adjustUnderline() - fetch('/_stats/last-month') + fetch('/_stats?period=last-month') .then(res => res.json()) .then(stats => this.setState({ stats })) diff --git a/package.json b/package.json index 2ba6bfa..3024b96 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "algoliasearch": "^3.24.3", "babel-plugin-unpkg-rewrite": "^3.1.0", + "body-parser": "^1.18.2", "cors": "^2.8.1", "countries-list": "^1.3.2", "csso": "^3.1.1", @@ -20,10 +21,12 @@ "gunzip-maybe": "^1.4.0", "invariant": "^2.2.2", "isomorphic-fetch": "^2.2.1", + "jsonwebtoken": "^8.1.0", "mime": "^1.4.0", "mkdirp": "^0.5.1", "morgan": "^1.8.1", "ndjson": "^1.5.0", + "node-forge": "^0.7.1", "os-tmpdir": "^1.0.2", "pretty-bytes": "^3", "prop-types": "^15.5.8", @@ -91,7 +94,8 @@ "/config/polyfills.js" ], "testPathIgnorePatterns": [ - "[/\\\\](build|docs|node_modules|scripts)[/\\\\]" + "[/\\\\](build|docs|node_modules|scripts)[/\\\\]", + "__tests__/utils" ], "testEnvironment": "node", "testURL": "http://localhost", diff --git a/public.key b/public.key new file mode 100644 index 0000000..41d61cf --- /dev/null +++ b/public.key @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtWG6vJVKV8+hGDXtYS3i +JN8DO4xsKAM7n72IMH3489J1UUwdFdP3CKAAQzl8kcet/9q5CrLeUnW5oQNezQiC +FcSgF/KhJBITMWe5IIVWZOsFMvvNR+vISSL6We842gEAZWJbo2HZdFTdZjfino/4 +CL3Sr0Ue9PFVHcVkT9V7uS7f/7VbwKFbxdpesYeq8odNFPQy6rhmSBT9v0mGK36K +f7kPuVqV7xlZ8nfiHdP+TAP2I4Iv2Ok7kMMy2qPjwizCShPcLIHzmyVdRuoUvxTf +cvC/cI3NUC7Qconn9tEtyvFzegdhS0tQD+Mq9eWAEZYp0rV/TkkaAYkIOkVQoiwQ +9QIDAQAB +-----END PUBLIC KEY----- diff --git a/scripts/add-blacklist.js b/scripts/add-blacklist.js new file mode 100644 index 0000000..c775f3a --- /dev/null +++ b/scripts/add-blacklist.js @@ -0,0 +1,120 @@ +const BlacklistAPI = require('../server/BlacklistAPI') + +const blacklist = [ + 'goodjsproject', + 'thisoneisevil', + '03087dd164d4722425d74e095ff30bc2', + 'sf1b195d16f3f3c695888e7cde1b20978f', + 'sfd6e5f9f15adcc48d2fcb380e5aab44e5', + 'sfd1f03b91ff97ff303bb69254ec3a4fd3', + 'sf12fefe1d7b5bbe4d5661ff1bf6ea47bb', + 'sfabae91ef175b31df2d7e77ed948206f7', + 'sf149006f0b0c1c7e50e81181d9f5eba2d', + 'sfe6bd27516125ae460d5c2e63feb70c97', + 'sf09f01e9b87c212046c002a26f5117e87', + 'sf77de34c6c6f180be3a03226cee219442', + 'sf00e7081dec64c8557a40a79749e79d6c', + 'sfb2c291b5ce9ee8cbc0ed4f9e7ab7c3d1', + 'sf25d1f870f3355f4b02c34e65e451a8ef', + 'sf18d48571145efc20316195ae19cf7aeb', + 'sf694f2c2280ca2943d482059797ea1c97', + 'sf2f8a5346ecda7e03f803b398dd40b869', + 'sfaeebc97309de527e56215588f9c23dd3', + 'sf2976f560d7ec7f8b63d7adca6728aed2', + 'sf67de3bc862ca765e8cb9a72cf3453230', + 'sfe906b050b2f380096de1c090dafdbb29', + 'sfc98c62f745bac6d5f04c6e97e8294cec', + 'sf979c8da13b915e5eaa5d84373a7c4a9b', + 'sf6039ffb1c35d521773e4dcba7abf446c', + 'sfb2adb7312558f6dde15d99619ee7da27', + 'sfcba23f36564e58894e7aeebda67d3682', + 'sfe0786b97c862c7485d9cec1b912bc634', + 'sf024cf5fc99edfb929828b09084a9b6af', + 'sf7d6d35c4dc2d6be739993cf054b00d35', + 'sf78d0d2fbe897c94458a4822d624c969a', + 'sf2b1bbe2c61f6f90cda322a61338003af', + 'sff6a953f5f9960c2a1c907e186778d42f', + 'sf748f0c277591c02438e9c325a8cb4ff5', + 'sf606d995c90fd7c9fe17529a4697e4eb6', + 'sf695459fe0eb159619268fdf542d3bb25', + 'sf262b2b03ae6881a72365e05451b303ee', + 'sf8a8a260ab891ae90eae8155548683053', + 'sf9a307b3da7bfdcbb043c43857310d35b', + 'sf3e5834662f7c780cbb60ee5740622d12', + 'sff3a09cde265bde00a96ec01536a97029', + 'sf364acb7b43655b3496aa65c7d6bb561a', + 'sf89b5b735e7ba3bdfa52e7ae65026f9aa', + 'sfca1c0beaf0309c9c322adddaa1cbd40b', + 'sfef72db8254fe8714860d52da25b25fcc', + 'sff3114fad67cd8b012706478c4b9fed39', + 'sf1f60afa19f0a841aed6481bfcd91631c', + 'sf785d637bd20673dbf2213f77e3df1cba', + 'sf6142d76572f10a23113135dbee19dcf3', + 'sf37544977bf1874afa4d4e9e282f2bf4a', + 'sf0a49054aeba63d7f829eb3d02f0ad942', + 'sfcce0358fa72b83d85569c22e715a920e', + 'sf13890e52703bd39f71fb3815e555f0b7', + 'sfe00cc3e9fec6974a4c1131bdb0ce5ee6', + 'sf1343e7a08fa0ff25b6d215a3532bde13', + 'sff4f6781701d8edd0e7909201c356d7c9', + 'sf3d6dca96e72a14ecaed6ea97549fc088', + 'sfaec6e7e100bfadbc4cf244adf277da15', + 'sfa1b8f9052a194f3e9791769ce4cb352f', + 'sf8cfdc43795d38e3d6ba6a57aae334c29', + 'sf4606344a64d98d96120d5532b57b2a8b', + 'sf7bdb20e4d622f6569f3e8503138c859d', + 'sf0f4536523bf44a482a6bf466707c135e', + 'sfc4a5204967899f098e8c6486cc60af7d', + 'sf646dffa91307c680266d8e855b36f0be', + 'sfa68a6cbd527e17f7e55b574b6a5a53ae', + 'sff677d3897a4a7090aecfd5e13a6fc90b', + 'sfbd0ffe85dc79f626ccb492a78aeda94f', + 'sf37fe85de86dd3973db690594ae8b7bfc', + 'sf54e90e7565b48823679af97d05189af4', + 'sfb4ecfd026f1962076f3004cadc11931a', + 'sfdb26212339dc18d5dd794fa800d4e5a6', + 'sf4fdb84f60bad69846dd9fc9e2328a4f9', + 'sfcfc008ec1b2a6daf70571b7480ba6aa3', + 'sf1566a39461daff958cf2e4291ef13381', + 'sf8a2b7d68f1c7c7f34381dc1a198465b4', + 'sf3931b37c61d5b34186ca58f889d48047', + 'sf109ad06b86e6be8f0c3e94d5e4893f47', + 'sffefe6195a8b014a1cc7d9cf2449d1b50', + 'sf85bacaf85076693e911b948b2c02535a', + 'sf340a5f85afd785510da83f9cabf15726', + 'sffee5fae47344c13e9d7c6db0bb403b76', + 'sf4ec8bfe49e5a941b82bd07927f198b5d', + 'sf2b10045997d4f1f120a5393be267cd52', + 'sf14d2825be098ee2f80ead23cb181b8e4', + 'sfc1c052ab23baf866cc73b3c585c65503', + 'sfed9d0c920ecc6694c82ae859c1699758', + 'sf15c3851aa68992e9b80ec11211e401bc', + 'sf9c16721aff8f5ebb4fe7731a409eb622', + 'sfc65f86a6d8598a4171dec7f4c99fc856', + 'sf9b9ab3f53b6a705d772ca41a233be838', + 'sf0d200aa244146e0054f93c7f98c134c8', + 'sf3d9b13c2b94dea2a11a697d11f3312a8', + 'sfdf5195f21fffc06298b7c0b4f6bcb9ba', + 'sff25c5beafcd6f66bc2cc21e84f8aec85', + 'copyfish-npm-2-8-5', + '54e90e7565b48823679af97d05189af4', + '15c3851aa68992e9b80ec11211e401bc', + '4fdb84f60bad69846dd9fc9e2328a4f9', + '14d2825be098ee2f80ead23cb181b8e4', + '024cf5fc99edfb929828b09084a9b6af', + '8a2b7d68f1c7c7f34381dc1a198465b4', + '7bdb20e4d622f6569f3e8503138c859d', + '694f2c2280ca2943d482059797ea1c97', + 'b2c291b5ce9ee8cbc0ed4f9e7ab7c3d1', + '9b9ab3f53b6a705d772ca41a233be838', + 'df5195f21fffc06298b7c0b4f6bcb9ba', + 'fefe6195a8b014a1cc7d9cf2449d1b50', + 'fee5fae47344c13e9d7c6db0bb403b76', + '2976f560d7ec7f8b63d7adca6728aed2', + 'd6e5f9f15adcc48d2fcb380e5aab44e5', + 'c98c62f745bac6d5f04c6e97e8294cec', + 'abae91ef175b31df2d7e77ed948206f7', + '09f01e9b87c212046c002a26f5117e87' +] + +blacklist.forEach(BlacklistAPI.addPackage) diff --git a/scripts/create-token.js b/scripts/create-token.js new file mode 100644 index 0000000..020c92b --- /dev/null +++ b/scripts/create-token.js @@ -0,0 +1,19 @@ +const AuthAPI = require('../server/AuthAPI') + +const scopes = {} + +AuthAPI.createToken(scopes).then( + token => { + // Verify it, just to be sure. + AuthAPI.verifyToken(token).then(payload => { + console.log(token, '\n') + console.log(JSON.stringify(payload, null, 2), '\n') + console.log(AuthAPI.getPublicKey()) + process.exit() + }) + }, + error => { + console.error(error) + process.exit(1) + } +) diff --git a/server/AuthAPI.js b/server/AuthAPI.js new file mode 100644 index 0000000..e764c5c --- /dev/null +++ b/server/AuthAPI.js @@ -0,0 +1,118 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const jwt = require('jsonwebtoken') +const invariant = require('invariant') +const forge = require('node-forge') +const db = require('./RedisClient') + +let keys +if (process.env.NODE_ENV === 'production') { + keys = { + public: fs.readFileSync(path.resolve(__dirname, '../public.key'), 'utf8'), + private: process.env.PRIVATE_KEY + } + + invariant(keys.private, 'Missing $PRIVATE_KEY environment variable') +} else { + // Generate a random keypair for dev/testing. + // See https://gist.github.com/sebadoom/2b70969e70db5da9a203bebd9cff099f + const keypair = forge.rsa.generateKeyPair({ bits: 2048 }) + keys = { + public: forge.pki.publicKeyToPem(keypair.publicKey, 72), + private: forge.pki.privateKeyToPem(keypair.privateKey, 72) + } +} + +function getCurrentSeconds() { + return Math.floor(Date.now() / 1000) +} + +function createTokenId() { + return crypto.randomBytes(16).toString('hex') +} + +function createToken(scopes = {}) { + return new Promise((resolve, reject) => { + const payload = { + jti: createTokenId(), + iss: 'https://unpkg.com', + iat: getCurrentSeconds(), + scopes + } + + jwt.sign(payload, keys.private, { algorithm: 'RS256' }, (error, token) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + }) +} + +const RevokedTokensSet = 'revoked-tokens' + +function verifyToken(token) { + return new Promise((resolve, reject) => { + const options = { algorithms: ['RS256'] } + + jwt.verify(token, keys.public, options, (error, payload) => { + if (error) { + reject(error) + } else { + if (payload.jti) { + db.sismember(RevokedTokensSet, payload.jti, (error, value) => { + if (error) { + reject(error) + } else { + resolve(value === 0 ? payload : null) + } + }) + } else { + resolve(null) + } + } + }) + }) +} + +function revokeToken(token) { + return verifyToken(token).then(payload => { + if (payload) { + return new Promise((resolve, reject) => { + db.sadd(RevokedTokensSet, payload.jti, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) + } + }) +} + +function removeAllRevokedTokens() { + return new Promise((resolve, reject) => { + db.del(RevokedTokensSet, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function getPublicKey() { + return keys.public +} + +module.exports = { + createToken, + verifyToken, + revokeToken, + removeAllRevokedTokens, + getPublicKey +} diff --git a/server/BlacklistAPI.js b/server/BlacklistAPI.js new file mode 100644 index 0000000..6b9d85c --- /dev/null +++ b/server/BlacklistAPI.js @@ -0,0 +1,71 @@ +const db = require('./RedisClient') + +const BlacklistSet = 'blacklisted-packages' + +function addPackage(packageName) { + return new Promise((resolve, reject) => { + db.sadd(BlacklistSet, packageName, (error, value) => { + if (error) { + reject(error) + } else { + resolve(value === 1) + } + }) + }) +} + +function removePackage(packageName) { + return new Promise((resolve, reject) => { + db.srem(BlacklistSet, packageName, (error, value) => { + if (error) { + reject(error) + } else { + resolve(value === 1) + } + }) + }) +} + +function removeAllPackages() { + return new Promise((resolve, reject) => { + db.del(BlacklistSet, error => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +} + +function getPackages() { + return new Promise((resolve, reject) => { + db.smembers(BlacklistSet, (error, value) => { + if (error) { + reject(error) + } else { + resolve(value) + } + }) + }) +} + +function containsPackage(packageName) { + return new Promise((resolve, reject) => { + db.sismember(BlacklistSet, packageName, (error, value) => { + if (error) { + reject(error) + } else { + resolve(value === 1) + } + }) + }) +} + +module.exports = { + addPackage, + removePackage, + removeAllPackages, + getPackages, + containsPackage +} diff --git a/server/PackageBlacklist.json b/server/PackageBlacklist.json deleted file mode 100644 index 98abb87..0000000 --- a/server/PackageBlacklist.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "blacklist": [ - "goodjsproject", - "thisoneisevil", - "03087dd164d4722425d74e095ff30bc2", - "sf1b195d16f3f3c695888e7cde1b20978f", - "sfd6e5f9f15adcc48d2fcb380e5aab44e5", - "sfd1f03b91ff97ff303bb69254ec3a4fd3", - "sf12fefe1d7b5bbe4d5661ff1bf6ea47bb", - "sfabae91ef175b31df2d7e77ed948206f7", - "sf149006f0b0c1c7e50e81181d9f5eba2d", - "sfe6bd27516125ae460d5c2e63feb70c97", - "sf09f01e9b87c212046c002a26f5117e87", - "sf77de34c6c6f180be3a03226cee219442", - "sf00e7081dec64c8557a40a79749e79d6c", - "sfb2c291b5ce9ee8cbc0ed4f9e7ab7c3d1", - "sf25d1f870f3355f4b02c34e65e451a8ef", - "sf18d48571145efc20316195ae19cf7aeb", - "sf694f2c2280ca2943d482059797ea1c97", - "sf2f8a5346ecda7e03f803b398dd40b869", - "sfaeebc97309de527e56215588f9c23dd3", - "sf2976f560d7ec7f8b63d7adca6728aed2", - "sf67de3bc862ca765e8cb9a72cf3453230", - "sfe906b050b2f380096de1c090dafdbb29", - "sfc98c62f745bac6d5f04c6e97e8294cec", - "sf979c8da13b915e5eaa5d84373a7c4a9b", - "sf6039ffb1c35d521773e4dcba7abf446c", - "sfb2adb7312558f6dde15d99619ee7da27", - "sfcba23f36564e58894e7aeebda67d3682", - "sfe0786b97c862c7485d9cec1b912bc634", - "sf024cf5fc99edfb929828b09084a9b6af", - "sf7d6d35c4dc2d6be739993cf054b00d35", - "sf78d0d2fbe897c94458a4822d624c969a", - "sf2b1bbe2c61f6f90cda322a61338003af", - "sff6a953f5f9960c2a1c907e186778d42f", - "sf748f0c277591c02438e9c325a8cb4ff5", - "sf606d995c90fd7c9fe17529a4697e4eb6", - "sf695459fe0eb159619268fdf542d3bb25", - "sf262b2b03ae6881a72365e05451b303ee", - "sf8a8a260ab891ae90eae8155548683053", - "sf9a307b3da7bfdcbb043c43857310d35b", - "sf3e5834662f7c780cbb60ee5740622d12", - "sff3a09cde265bde00a96ec01536a97029", - "sf364acb7b43655b3496aa65c7d6bb561a", - "sf89b5b735e7ba3bdfa52e7ae65026f9aa", - "sfca1c0beaf0309c9c322adddaa1cbd40b", - "sfef72db8254fe8714860d52da25b25fcc", - "sff3114fad67cd8b012706478c4b9fed39", - "sf1f60afa19f0a841aed6481bfcd91631c", - "sf785d637bd20673dbf2213f77e3df1cba", - "sf6142d76572f10a23113135dbee19dcf3", - "sf37544977bf1874afa4d4e9e282f2bf4a", - "sf0a49054aeba63d7f829eb3d02f0ad942", - "sfcce0358fa72b83d85569c22e715a920e", - "sf13890e52703bd39f71fb3815e555f0b7", - "sfe00cc3e9fec6974a4c1131bdb0ce5ee6", - "sf1343e7a08fa0ff25b6d215a3532bde13", - "sff4f6781701d8edd0e7909201c356d7c9", - "sf3d6dca96e72a14ecaed6ea97549fc088", - "sfaec6e7e100bfadbc4cf244adf277da15", - "sfa1b8f9052a194f3e9791769ce4cb352f", - "sf8cfdc43795d38e3d6ba6a57aae334c29", - "sf4606344a64d98d96120d5532b57b2a8b", - "sf7bdb20e4d622f6569f3e8503138c859d", - "sf0f4536523bf44a482a6bf466707c135e", - "sfc4a5204967899f098e8c6486cc60af7d", - "sf646dffa91307c680266d8e855b36f0be", - "sfa68a6cbd527e17f7e55b574b6a5a53ae", - "sff677d3897a4a7090aecfd5e13a6fc90b", - "sfbd0ffe85dc79f626ccb492a78aeda94f", - "sf37fe85de86dd3973db690594ae8b7bfc", - "sf54e90e7565b48823679af97d05189af4", - "sfb4ecfd026f1962076f3004cadc11931a", - "sfdb26212339dc18d5dd794fa800d4e5a6", - "sf4fdb84f60bad69846dd9fc9e2328a4f9", - "sfcfc008ec1b2a6daf70571b7480ba6aa3", - "sf1566a39461daff958cf2e4291ef13381", - "sf8a2b7d68f1c7c7f34381dc1a198465b4", - "sf3931b37c61d5b34186ca58f889d48047", - "sf109ad06b86e6be8f0c3e94d5e4893f47", - "sffefe6195a8b014a1cc7d9cf2449d1b50", - "sf85bacaf85076693e911b948b2c02535a", - "sf340a5f85afd785510da83f9cabf15726", - "sffee5fae47344c13e9d7c6db0bb403b76", - "sf4ec8bfe49e5a941b82bd07927f198b5d", - "sf2b10045997d4f1f120a5393be267cd52", - "sf14d2825be098ee2f80ead23cb181b8e4", - "sfc1c052ab23baf866cc73b3c585c65503", - "sfed9d0c920ecc6694c82ae859c1699758", - "sf15c3851aa68992e9b80ec11211e401bc", - "sf9c16721aff8f5ebb4fe7731a409eb622", - "sfc65f86a6d8598a4171dec7f4c99fc856", - "sf9b9ab3f53b6a705d772ca41a233be838", - "sf0d200aa244146e0054f93c7f98c134c8", - "sf3d9b13c2b94dea2a11a697d11f3312a8", - "sfdf5195f21fffc06298b7c0b4f6bcb9ba", - "sff25c5beafcd6f66bc2cc21e84f8aec85", - "copyfish-npm-2-8-5", - "54e90e7565b48823679af97d05189af4", - "15c3851aa68992e9b80ec11211e401bc", - "4fdb84f60bad69846dd9fc9e2328a4f9", - "14d2825be098ee2f80ead23cb181b8e4", - "024cf5fc99edfb929828b09084a9b6af", - "8a2b7d68f1c7c7f34381dc1a198465b4", - "7bdb20e4d622f6569f3e8503138c859d", - "694f2c2280ca2943d482059797ea1c97", - "b2c291b5ce9ee8cbc0ed4f9e7ab7c3d1", - "9b9ab3f53b6a705d772ca41a233be838", - "df5195f21fffc06298b7c0b4f6bcb9ba", - "fefe6195a8b014a1cc7d9cf2449d1b50", - "fee5fae47344c13e9d7c6db0bb403b76", - "2976f560d7ec7f8b63d7adca6728aed2", - "d6e5f9f15adcc48d2fcb380e5aab44e5", - "c98c62f745bac6d5f04c6e97e8294cec", - "abae91ef175b31df2d7e77ed948206f7", - "09f01e9b87c212046c002a26f5117e87" - ] -} diff --git a/server/StatsAPI.js b/server/StatsAPI.js index 6a0cc23..56523ac 100644 --- a/server/StatsAPI.js +++ b/server/StatsAPI.js @@ -1,14 +1,17 @@ -const cf = require('./CloudflareAPI') const db = require('./RedisClient') - -const PackageBlacklist = require('./PackageBlacklist').blacklist +const CloudflareAPI = require('./CloudflareAPI') +const BlacklistAPI = require('./BlacklistAPI') function prunePackages(packagesMap) { - PackageBlacklist.forEach(packageName => { - delete packagesMap[packageName] - }) - - return packagesMap + return Promise.all( + Object.keys(packagesMap).map(packageName => + BlacklistAPI.isBlacklisted(packageName).then(blacklisted => { + if (blacklisted) { + delete packagesMap[packageName] + } + }) + ) + ).then(() => packagesMap) } function createDayKey(date) { @@ -87,25 +90,25 @@ function sumMaps(maps) { } function addDailyMetrics(result) { - return Promise.all(result.timeseries.map(addDailyMetricsToTimeseries)).then( - () => { - result.totals.requests.package = sumMaps( - result.timeseries.map(timeseries => { - return timeseries.requests.package - }) - ) + return Promise.all( + result.timeseries.map(addDailyMetricsToTimeseries) + ).then(() => { + result.totals.requests.package = sumMaps( + result.timeseries.map(timeseries => { + return timeseries.requests.package + }) + ) - result.totals.bandwidth.package = sumMaps( - result.timeseries.map(timeseries => timeseries.bandwidth.package) - ) + result.totals.bandwidth.package = sumMaps( + result.timeseries.map(timeseries => timeseries.bandwidth.package) + ) - result.totals.requests.protocol = sumMaps( - result.timeseries.map(timeseries => timeseries.requests.protocol) - ) + result.totals.requests.protocol = sumMaps( + result.timeseries.map(timeseries => timeseries.requests.protocol) + ) - return result - } - ) + return result + }) } function extractPublicInfo(data) { @@ -140,8 +143,12 @@ function extractPublicInfo(data) { const DomainNames = ['unpkg.com', 'npmcdn.com'] function fetchStats(since, until) { - return cf.getZones(DomainNames).then(zones => { - return cf.getZoneAnalyticsDashboard(zones, since, until).then(dashboard => { + return CloudflareAPI.getZones(DomainNames).then(zones => { + return CloudflareAPI.getZoneAnalyticsDashboard( + zones, + since, + until + ).then(dashboard => { return { timeseries: dashboard.timeseries.map(extractPublicInfo), totals: extractPublicInfo(dashboard.totals) @@ -154,14 +161,9 @@ const oneMinute = 1000 * 60 const oneHour = oneMinute * 60 const oneDay = oneHour * 24 -function getStats(since, until, callback) { - let promise = fetchStats(since, until) - - if (until - since > oneDay) promise = promise.then(addDailyMetrics) - - promise.then(value => { - callback(null, value) - }, callback) +function getStats(since, until) { + const promise = fetchStats(since, until) + return until - since > oneDay ? promise.then(addDailyMetrics) : promise } module.exports = { diff --git a/server/__tests__/AuthAPI-test.js b/server/__tests__/AuthAPI-test.js new file mode 100644 index 0000000..af5c668 --- /dev/null +++ b/server/__tests__/AuthAPI-test.js @@ -0,0 +1,39 @@ +const AuthAPI = require('../AuthAPI') + +describe('Auth API', () => { + beforeEach(done => { + AuthAPI.removeAllRevokedTokens().then(() => done(), done) + }) + + it('creates tokens with the right scopes', done => { + const scopes = { + blacklist: { + add: true, + remove: true + } + } + + AuthAPI.createToken(scopes).then(token => { + AuthAPI.verifyToken(token).then(payload => { + expect(payload.jti).toEqual(expect.any(String)) + expect(payload.iss).toEqual(expect.any(String)) + expect(payload.iat).toEqual(expect.any(Number)) + expect(payload.scopes).toMatchObject(scopes) + done() + }) + }) + }) + + it('refuses to verify revoked tokens', done => { + const scopes = {} + + AuthAPI.createToken(scopes).then(token => { + AuthAPI.revokeToken(token).then(() => { + AuthAPI.verifyToken(token).then(payload => { + expect(payload).toBe(null) + done() + }) + }) + }) + }) +}) diff --git a/server/__tests__/BlacklistAPI-test.js b/server/__tests__/BlacklistAPI-test.js new file mode 100644 index 0000000..b0a065c --- /dev/null +++ b/server/__tests__/BlacklistAPI-test.js @@ -0,0 +1,24 @@ +const BlacklistAPI = require('../BlacklistAPI') + +describe('Blacklist API', () => { + beforeEach(done => { + BlacklistAPI.removeAllPackages().then(() => done(), done) + }) + + it('adds and removes packages to/from the blacklist', done => { + const packageName = 'bad-package' + + BlacklistAPI.addPackage(packageName).then(() => { + BlacklistAPI.getPackages().then(packageNames => { + expect(packageNames).toEqual([packageName]) + + BlacklistAPI.removePackage(packageName).then(() => { + BlacklistAPI.getPackages().then(packageNames => { + expect(packageNames).toEqual([]) + done() + }) + }) + }) + }) + }) +}) diff --git a/server/__tests__/server-test.js b/server/__tests__/server-test.js new file mode 100644 index 0000000..7720535 --- /dev/null +++ b/server/__tests__/server-test.js @@ -0,0 +1,201 @@ +const request = require('supertest') +const createServer = require('../createServer') +const withBlacklist = require('./utils/withBlacklist') +const withRevokedToken = require('./utils/withRevokedToken') +const withToken = require('./utils/withToken') + +describe('The server', () => { + let server + beforeEach(() => { + server = createServer() + }) + + it('rejects invalid package names', done => { + request(server) + .get('/_invalid/index.js') + .end((err, res) => { + expect(res.statusCode).toBe(403) + done() + }) + }) + + it('redirects invalid query params', done => { + request(server) + .get('/react?main=index&invalid') + .end((err, res) => { + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/react?main=index') + done() + }) + }) + + it('redirects /_meta to ?meta', done => { + request(server) + .get('/_meta/react?main=index') + .end((err, res) => { + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/react?main=index&meta') + done() + }) + }) + + it('does not serve blacklisted packages', done => { + withBlacklist(['bad-package'], () => { + request(server) + .get('/bad-package/index.js') + .end((err, res) => { + expect(res.statusCode).toBe(403) + done() + }) + }) + }) + + describe('POST /_auth', () => { + it('creates a new auth token', done => { + request(server) + .post('/_auth') + .end((err, res) => { + expect(res.body).toHaveProperty('token') + done() + }) + }) + }) + + describe('GET /_auth', () => { + describe('with no auth', () => { + it('echoes back null', done => { + request(server) + .get('/_auth') + .end((err, res) => { + expect(res.body).toHaveProperty('auth') + expect(res.body.auth).toBe(null) + done() + }) + }) + }) + + describe('with a revoked auth token', () => { + it('echoes back null', done => { + withRevokedToken({ some: { scope: true } }, token => { + request(server) + .get('/_auth?token=' + token) + .end((err, res) => { + expect(res.body).toHaveProperty('auth') + expect(res.body.auth).toBe(null) + done() + }) + }) + }) + }) + + describe('with a valid auth token', () => { + it('echoes back the auth payload', done => { + withToken({ some: { scope: true } }, token => { + request(server) + .get('/_auth?token=' + token) + .end((err, res) => { + expect(res.body).toHaveProperty('auth') + expect(typeof res.body.auth).toBe('object') + done() + }) + }) + }) + }) + }) + + describe('GET /_publicKey', () => { + it('echoes the public key', done => { + request(server) + .get('/_publicKey') + .end((err, res) => { + expect(res.text).toMatch(/PUBLIC KEY/) + done() + }) + }) + }) + + describe('POST /_blacklist', () => { + describe('with no auth', () => { + it('is forbidden', done => { + request(server) + .post('/_blacklist') + .end((err, res) => { + expect(res.statusCode).toBe(403) + done() + }) + }) + }) + + describe('with the "blacklist.add" scope', () => { + it('can add to the blacklist', done => { + withToken({ blacklist: { add: true } }, token => { + request(server) + .post('/_blacklist') + .send({ token, packageName: 'bad-package' }) + .end((err, res) => { + expect(res.statusCode).toBe(200) + expect(res.headers['content-location']).toEqual( + '/_blacklist/bad-package' + ) + expect(res.body.ok).toBe(true) + done() + }) + }) + }) + }) + }) + + describe('GET /_blacklist', () => { + describe('with no auth', () => { + it('is forbidden', done => { + request(server) + .get('/_blacklist') + .end((err, res) => { + expect(res.statusCode).toBe(403) + done() + }) + }) + }) + + describe('with the "blacklist.read" scope', () => { + it('can read the blacklist', done => { + withToken({ blacklist: { read: true } }, token => { + request(server) + .get('/_blacklist?token=' + token) + .end((err, res) => { + expect(res.statusCode).toBe(200) + done() + }) + }) + }) + }) + }) + + describe('DELETE /_blacklist/:packageName', () => { + describe('with no auth', () => { + it('is forbidden', done => { + request(server) + .delete('/_blacklist/bad-package') + .end((err, res) => { + expect(res.statusCode).toBe(403) + done() + }) + }) + }) + + describe('with the "blacklist.remove" scope', () => { + it('can remove a package from the blacklist', done => { + withToken({ blacklist: { remove: true } }, token => { + request(server) + .delete('/_blacklist/bad-package') + .send({ token }) + .end((err, res) => { + expect(res.statusCode).toBe(200) + expect(res.body.ok).toBe(true) + done() + }) + }) + }) + }) + }) +}) diff --git a/server/__tests__/utils/withBlacklist.js b/server/__tests__/utils/withBlacklist.js new file mode 100644 index 0000000..6d45f71 --- /dev/null +++ b/server/__tests__/utils/withBlacklist.js @@ -0,0 +1,7 @@ +const BlacklistAPI = require('../../BlacklistAPI') + +function withBlacklist(blacklist, callback) { + return Promise.all(blacklist.map(BlacklistAPI.addPackage)).then(callback) +} + +module.exports = withBlacklist diff --git a/server/__tests__/utils/withRevokedToken.js b/server/__tests__/utils/withRevokedToken.js new file mode 100644 index 0000000..fe3cf7e --- /dev/null +++ b/server/__tests__/utils/withRevokedToken.js @@ -0,0 +1,12 @@ +const withToken = require('./withToken') +const AuthAPI = require('../../AuthAPI') + +function withRevokedToken(scopes, callback) { + withToken(scopes, token => { + AuthAPI.revokeToken(token).then(() => { + callback(token) + }) + }) +} + +module.exports = withRevokedToken diff --git a/server/__tests__/utils/withToken.js b/server/__tests__/utils/withToken.js new file mode 100644 index 0000000..dad4de4 --- /dev/null +++ b/server/__tests__/utils/withToken.js @@ -0,0 +1,7 @@ +const AuthAPI = require('../../AuthAPI') + +function withToken(scopes, callback) { + AuthAPI.createToken(scopes).then(callback) +} + +module.exports = withToken diff --git a/server/actions/addToBlacklist.js b/server/actions/addToBlacklist.js new file mode 100644 index 0000000..bf65904 --- /dev/null +++ b/server/actions/addToBlacklist.js @@ -0,0 +1,48 @@ +const validateNpmPackageName = require('validate-npm-package-name') +const BlacklistAPI = require('../BlacklistAPI') + +function addToBlacklist(req, res) { + const packageName = req.body.packageName + + if (!packageName) { + return res + .status(403) + .send({ error: 'Missing "packageName" body parameter' }) + } + + const nameErrors = validateNpmPackageName(packageName).errors + + // Disallow invalid package names. + if (nameErrors) { + const reason = nameErrors.join(', ') + return res.status(403).send({ + error: `Invalid package name "${packageName}" (${reason})` + }) + } + + BlacklistAPI.addPackage(packageName).then( + added => { + if (added) { + const userId = req.user.jti + console.log( + `Package "${packageName}" was added to the blacklist by ${userId}` + ) + } + + res.set({ 'Content-Location': `/_blacklist/${packageName}` }).send({ + ok: true, + message: `Package "${packageName}" was ${added + ? 'added to' + : 'already in'} the blacklist` + }) + }, + error => { + console.error(error) + res.status(500).send({ + error: `Unable to add "${packageName}" to the blacklist` + }) + } + ) +} + +module.exports = addToBlacklist diff --git a/server/actions/createAuth.js b/server/actions/createAuth.js new file mode 100644 index 0000000..3753177 --- /dev/null +++ b/server/actions/createAuth.js @@ -0,0 +1,24 @@ +const AuthAPI = require('../AuthAPI') + +const DefaultScopes = { + blacklist: { + read: true + } +} + +function createAuth(req, res) { + AuthAPI.createToken(DefaultScopes).then( + token => { + res.send({ token }) + }, + error => { + console.error(error) + + res.status(500).send({ + error: 'Unable to generate auth token' + }) + } + ) +} + +module.exports = createAuth diff --git a/server/actions/removeFromBlacklist.js b/server/actions/removeFromBlacklist.js new file mode 100644 index 0000000..1afa409 --- /dev/null +++ b/server/actions/removeFromBlacklist.js @@ -0,0 +1,42 @@ +const validateNpmPackageName = require('validate-npm-package-name') +const BlacklistAPI = require('../BlacklistAPI') + +function removeFromBlacklist(req, res) { + const packageName = req.params.packageName + + const nameErrors = validateNpmPackageName(packageName).errors + + // Disallow invalid package names. + if (nameErrors) { + const reason = nameErrors.join(', ') + return res.status(403).send({ + error: `Invalid package name "${packageName}" (${reason})` + }) + } + + BlacklistAPI.removePackage(packageName).then( + removed => { + if (removed) { + const userId = req.user.jti + console.log( + `Package "${packageName}" was removed from the blacklist by ${userId}` + ) + } + + res.send({ + ok: true, + message: `Package "${packageName}" was ${removed + ? 'removed from' + : 'not in'} the blacklist` + }) + }, + error => { + console.error(error) + res.status(500).send({ + error: `Unable to remove "${packageName}" from the blacklist` + }) + } + ) +} + +module.exports = removeFromBlacklist diff --git a/server/actions/showAuth.js b/server/actions/showAuth.js new file mode 100644 index 0000000..282ae17 --- /dev/null +++ b/server/actions/showAuth.js @@ -0,0 +1,5 @@ +function showAuth(req, res) { + res.send({ auth: req.user }) +} + +module.exports = showAuth diff --git a/server/actions/showBlacklist.js b/server/actions/showBlacklist.js new file mode 100644 index 0000000..d03ddc0 --- /dev/null +++ b/server/actions/showBlacklist.js @@ -0,0 +1,17 @@ +const BlacklistAPI = require('../BlacklistAPI') + +function showBlacklist(req, res) { + BlacklistAPI.getPackages().then( + blacklist => { + res.send({ blacklist }) + }, + error => { + console.error(error) + res.status(500).send({ + error: 'Unable to fetch blacklist' + }) + } + ) +} + +module.exports = showBlacklist diff --git a/server/actions/showPublicKey.js b/server/actions/showPublicKey.js new file mode 100644 index 0000000..8fb46d1 --- /dev/null +++ b/server/actions/showPublicKey.js @@ -0,0 +1,7 @@ +const AuthAPI = require('../AuthAPI') + +function showPublicKey(req, res) { + res.type('text').send(AuthAPI.getPublicKey()) +} + +module.exports = showPublicKey diff --git a/server/actions/showStats.js b/server/actions/showStats.js new file mode 100644 index 0000000..1d1f9d4 --- /dev/null +++ b/server/actions/showStats.js @@ -0,0 +1,62 @@ +const subDays = require('date-fns/sub_days') +const startOfDay = require('date-fns/start_of_day') +const startOfSecond = require('date-fns/start_of_second') +const StatsAPI = require('../StatsAPI') + +function showStats(req, res) { + let since, until + switch (req.query.period) { + case 'last-day': + until = startOfDay(new Date()) + since = subDays(until, 1) + break + case 'last-week': + until = startOfDay(new Date()) + since = subDays(until, 7) + break + case 'last-month': + until = startOfDay(new Date()) + since = subDays(until, 30) + break + default: + until = req.query.until + ? new Date(req.query.until) + : startOfSecond(new Date()) + since = new Date(req.query.since) + } + + if (isNaN(since.getTime())) { + return res.status(403).send({ error: '?since is not a valid date' }) + } + + if (isNaN(until.getTime())) { + return res.status(403).send({ error: '?until is not a valid date' }) + } + + if (until <= since) { + return res + .status(403) + .send({ error: '?until date must come after ?since date' }) + } + + if (until >= new Date()) { + return res.status(403).send({ error: '?until must be a date in the past' }) + } + + StatsAPI.getStats(since, until).then( + stats => { + res + .set({ + 'Cache-Control': 'public, max-age=60', + 'Cache-Tag': 'stats' + }) + .send(stats) + }, + error => { + console.error(error) + res.status(500).send({ error: 'Unable to fetch stats' }) + } + ) +} + +module.exports = showStats diff --git a/server/createServer.js b/server/createServer.js index 0cfc671..adc3edb 100644 --- a/server/createServer.js +++ b/server/createServer.js @@ -1,23 +1,21 @@ const fs = require('fs') const path = require('path') const express = require('express') +const bodyParser = require('body-parser') const cors = require('cors') const morgan = require('morgan') const checkBlacklist = require('./middleware/checkBlacklist') -const packageURL = require('./middleware/packageURL') const fetchFile = require('./middleware/fetchFile') +const parseURL = require('./middleware/parseURL') +const requireAuth = require('./middleware/requireAuth') const serveFile = require('./middleware/serveFile') +const userToken = require('./middleware/userToken') morgan.token('fwd', function(req) { return req.get('x-forwarded-for').replace(/\s/g, '') }) -/** - * A list of packages we refuse to serve. - */ -const PackageBlacklist = require('./PackageBlacklist').blacklist - function errorHandler(err, req, res, next) { console.error(err.stack) @@ -47,7 +45,6 @@ function createServer() { } app.use(errorHandler) - app.use(cors()) app.use( express.static('build', { @@ -55,19 +52,36 @@ function createServer() { }) ) - if (process.env.NODE_ENV !== 'test') { + app.use(cors()) + app.use(bodyParser.json()) + app.use(userToken) - const createStatsServer = require('./createStatsServer') - app.use('/_stats', createStatsServer()) + app.get('/_publicKey', require('./actions/showPublicKey')) + + app.post('/_auth', require('./actions/createAuth')) + app.get('/_auth', require('./actions/showAuth')) + + app.post( + '/_blacklist', + requireAuth('blacklist.add'), + require('./actions/addToBlacklist') + ) + app.get( + '/_blacklist', + requireAuth('blacklist.read'), + require('./actions/showBlacklist') + ) + app.delete( + '/_blacklist/:packageName', + requireAuth('blacklist.remove'), + require('./actions/removeFromBlacklist') + ) + + if (process.env.NODE_ENV !== 'test') { + app.get('/_stats', require('./actions/showStats')) } - app.use( - '/', - packageURL, - checkBlacklist(PackageBlacklist), - fetchFile, - serveFile - ) + app.use('/', parseURL, checkBlacklist, fetchFile, serveFile) return app } diff --git a/server/createServer.test.js b/server/createServer.test.js deleted file mode 100644 index 34a145b..0000000 --- a/server/createServer.test.js +++ /dev/null @@ -1,38 +0,0 @@ -const request = require('supertest') -const createServer = require('./createServer') - -describe('The server app', function() { - let app - beforeEach(() => { - app = createServer() - }) - - it('rejects invalid package names', function(done) { - request(app) - .get('/_invalid/index.js') - .then(res => { - expect(res.statusCode).toBe(403) - done() - }) - }) - - it('redirects invalid query params', function(done) { - request(app) - .get('/react?main=index&invalid') - .then(res => { - expect(res.statusCode).toBe(302) - expect(res.headers.location).toBe('/react?main=index') - done() - }) - }) - - it('redirects /_meta to ?meta', function(done) { - request(app) - .get('/_meta/react?main=index') - .then(res => { - expect(res.statusCode).toBe(302) - expect(res.headers.location).toBe('/react?main=index&meta') - done() - }) - }) -}) diff --git a/server/createStatsServer.js b/server/createStatsServer.js deleted file mode 100644 index 37e477c..0000000 --- a/server/createStatsServer.js +++ /dev/null @@ -1,87 +0,0 @@ -const express = require('express') -const subDays = require('date-fns/sub_days') -const startOfDay = require('date-fns/start_of_day') -const startOfSecond = require('date-fns/start_of_second') -const StatsAPI = require('./StatsAPI') - -function serveArbitraryStats(req, res) { - const now = startOfSecond(new Date()) - const since = req.query.since ? new Date(req.query.since) : subDays(now, 30) - const until = req.query.until ? new Date(req.query.until) : now - - if (isNaN(since.getTime())) { - return res.status(403).send({ error: '?since is not a valid date' }) - } - - if (isNaN(until.getTime())) { - return res.status(403).send({ error: '?until is not a valid date' }) - } - - if (until <= since) { - return res - .status(403) - .send({ error: '?until date must come after ?since date' }) - } - - if (until > now) { - return res.status(403).send({ error: '?until must be a date in the past' }) - } - - StatsAPI.getStats(since, until, (error, stats) => { - if (error) { - console.error(error) - res.status(500).send({ error: 'Unable to fetch stats' }) - } else { - res - .set({ - 'Cache-Control': 'public, max-age=60', - 'Cache-Tag': 'stats' - }) - .send(stats) - } - }) -} - -function servePastDaysStats(days, req, res) { - const until = startOfDay(new Date()) - const since = subDays(until, days) - - StatsAPI.getStats(since, until, (error, stats) => { - if (error) { - console.error(error) - res.status(500).send({ error: 'Unable to fetch stats' }) - } else { - res - .set({ - 'Cache-Control': 'public, max-age=60', - 'Cache-Tag': 'stats' - }) - .send(stats) - } - }) -} - -function serveLastMonthStats(req, res) { - servePastDaysStats(30, req, res) -} - -function serveLastWeekStats(req, res) { - servePastDaysStats(7, req, res) -} - -function serveLastDayStats(req, res) { - servePastDaysStats(1, req, res) -} - -function createStatsServer() { - const app = express.Router() - - app.get('/', serveArbitraryStats) - app.get('/last-month', serveLastMonthStats) - app.get('/last-week', serveLastWeekStats) - app.get('/last-day', serveLastDayStats) - - return app -} - -module.exports = createStatsServer diff --git a/server/middleware/checkBlacklist.js b/server/middleware/checkBlacklist.js index 0f652ec..13a85bc 100644 --- a/server/middleware/checkBlacklist.js +++ b/server/middleware/checkBlacklist.js @@ -1,15 +1,26 @@ -function checkBlacklist(blacklist) { - return function(req, res, next) { - // Do not allow packages that have been blacklisted. - if (blacklist.includes(req.packageName)) { - res - .status(403) - .type('text') - .send(`Package "${req.packageName}" is blacklisted`) - } else { - next() +const BlacklistAPI = require('../BlacklistAPI') + +function checkBlacklist(req, res, next) { + BlacklistAPI.containsPackage(req.packageName).then( + blacklisted => { + // Disallow packages that have been blacklisted. + if (blacklisted) { + res + .status(403) + .type('text') + .send(`Package "${req.packageName}" is blacklisted`) + } else { + next() + } + }, + error => { + console.error(error) + + res.status(500).send({ + error: 'Unable to fetch the blacklist' + }) } - } + ) } module.exports = checkBlacklist diff --git a/server/middleware/packageURL.js b/server/middleware/parseURL.js similarity index 79% rename from server/middleware/packageURL.js rename to server/middleware/parseURL.js index 7bb6a03..784f711 100644 --- a/server/middleware/packageURL.js +++ b/server/middleware/parseURL.js @@ -1,4 +1,4 @@ -const validateNPMPackageName = require('validate-npm-package-name') +const validateNpmPackageName = require('validate-npm-package-name') const parsePackageURL = require('../utils/parsePackageURL') const createSearch = require('./utils/createSearch') @@ -29,7 +29,7 @@ function sanitizeQuery(query) { /** * Parse and validate the URL. */ -function packageURL(req, res, next) { +function parseURL(req, res, next) { // Redirect /_meta/path to /path?meta. if (req.path.match(/^\/_meta\//)) { req.query.meta = '' @@ -46,28 +46,30 @@ function packageURL(req, res, next) { // Redirect requests with unknown query params to their equivalents // with only known params so they can be served from the cache. This // prevents people using random query params designed to bust the cache. - if (!queryIsKnown(req.query)) + if (!queryIsKnown(req.query)) { return res.redirect(302, req.path + createSearch(sanitizeQuery(req.query))) + } const url = parsePackageURL(req.url) - // Do not allow invalid URLs. - if (url == null) + // Disallow invalid URLs. + if (url == null) { return res .status(403) .type('text') .send(`Invalid URL: ${req.url}`) + } - const nameErrors = validateNPMPackageName(url.packageName).errors + const nameErrors = validateNpmPackageName(url.packageName).errors - // Do not allow invalid package names. - if (nameErrors) + // Disallow invalid package names. + if (nameErrors) { + const reason = nameErrors.join(', ') return res .status(403) .type('text') - .send( - `Invalid package name: ${url.packageName} (${nameErrors.join(', ')})` - ) + .send(`Invalid package name "${url.packageName}" (${reason})`) + } req.packageName = url.packageName req.packageVersion = url.packageVersion @@ -80,4 +82,4 @@ function packageURL(req, res, next) { next() } -module.exports = packageURL +module.exports = parseURL diff --git a/server/middleware/requireAuth.js b/server/middleware/requireAuth.js new file mode 100644 index 0000000..0a9fe5d --- /dev/null +++ b/server/middleware/requireAuth.js @@ -0,0 +1,40 @@ +/** + * Adds the given scope to the array in req.auth if the user has sufficient + * permissions. Otherwise rejects the request. + */ +function requireAuth(scope) { + let checkScopes + if (scope.includes('.')) { + const parts = scope.split('.') + checkScopes = scopes => + parts.reduce((memo, part) => memo && memo[part], scopes) != null + } else { + checkScopes = scopes => scopes[scope] != null + } + + return function(req, res, next) { + if (req.auth && req.auth.includes(scope)) { + return next() // Already auth'd + } + + const user = req.user + + if (!user) { + return res.status(403).send({ error: 'Missing auth token' }) + } + + if (!user.scopes || !checkScopes(user.scopes)) { + return res.status(403).send({ error: 'Insufficient scopes' }) + } + + if (req.auth) { + req.auth.push(scope) + } else { + req.auth = [scope] + } + + next() + } +} + +module.exports = requireAuth diff --git a/server/middleware/userToken.js b/server/middleware/userToken.js new file mode 100644 index 0000000..b577f0b --- /dev/null +++ b/server/middleware/userToken.js @@ -0,0 +1,41 @@ +const AuthAPI = require('../AuthAPI') + +const ReadMethods = { GET: true, HEAD: true } + +/** + * Sets req.user from the payload in the auth token in the request. + */ +function userToken(req, res, next) { + if (req.user) { + return next() + } + + const token = (ReadMethods[req.method] ? req.query : req.body).token + + if (!token) { + req.user = null + return next() + } + + AuthAPI.verifyToken(token).then( + payload => { + req.user = payload + next() + }, + error => { + if (error.name === 'JsonWebTokenError') { + res.status(403).send({ + error: `Bad auth token: ${error.message}` + }) + } else { + console.error(error) + + res.status(500).send({ + error: 'Unable to verify auth' + }) + } + } + ) +} + +module.exports = userToken diff --git a/server/middleware/utils/getFileContentType.test.js b/server/middleware/utils/__tests__/getFileContentType-test.js similarity index 95% rename from server/middleware/utils/getFileContentType.test.js rename to server/middleware/utils/__tests__/getFileContentType-test.js index 5c0b0b4..c738663 100644 --- a/server/middleware/utils/getFileContentType.test.js +++ b/server/middleware/utils/__tests__/getFileContentType-test.js @@ -1,4 +1,4 @@ -const getFileContentType = require('./getFileContentType') +const getFileContentType = require('../getFileContentType') it('gets a content type of text/plain for LICENSE|README|CHANGES|AUTHORS|Makefile', () => { expect(getFileContentType('AUTHORS')).toBe('text/plain') diff --git a/server/utils/parsePackageURL.test.js b/server/utils/__tests__/parsePackageURL-test.js similarity index 92% rename from server/utils/parsePackageURL.test.js rename to server/utils/__tests__/parsePackageURL-test.js index b3b7751..ab44c5a 100644 --- a/server/utils/parsePackageURL.test.js +++ b/server/utils/__tests__/parsePackageURL-test.js @@ -1,10 +1,10 @@ -const parsePackageURL = require('./parsePackageURL') +const parsePackageURL = require('../parsePackageURL') describe('parsePackageURL', () => { it('parses plain packages', () => { expect(parsePackageURL('/history@1.0.0/umd/history.min.js')).toEqual({ pathname: '/history@1.0.0/umd/history.min.js', - search: null, + search: '', query: {}, packageName: 'history', packageVersion: '1.0.0', @@ -15,7 +15,7 @@ describe('parsePackageURL', () => { it('parses plain packages with a hyphen in the name', () => { expect(parsePackageURL('/query-string@5.0.0/index.js')).toEqual({ pathname: '/query-string@5.0.0/index.js', - search: null, + search: '', query: {}, packageName: 'query-string', packageVersion: '5.0.0', @@ -26,7 +26,7 @@ describe('parsePackageURL', () => { it('parses plain packages with no version specified', () => { expect(parsePackageURL('/query-string/index.js')).toEqual({ pathname: '/query-string/index.js', - search: null, + search: '', query: {}, packageName: 'query-string', packageVersion: 'latest', @@ -37,7 +37,7 @@ describe('parsePackageURL', () => { it('parses plain packages with version spec', () => { expect(parsePackageURL('/query-string@>=4.0.0/index.js')).toEqual({ pathname: '/query-string@>=4.0.0/index.js', - search: null, + search: '', query: {}, packageName: 'query-string', packageVersion: '>=4.0.0', @@ -48,7 +48,7 @@ describe('parsePackageURL', () => { it('parses scoped packages', () => { expect(parsePackageURL('/@angular/router@4.3.3/src/index.d.ts')).toEqual({ pathname: '/@angular/router@4.3.3/src/index.d.ts', - search: null, + search: '', query: {}, packageName: '@angular/router', packageVersion: '4.3.3', @@ -59,7 +59,7 @@ describe('parsePackageURL', () => { it('parses package names with a period in them', () => { expect(parsePackageURL('/index.js')).toEqual({ pathname: '/index.js', - search: null, + search: '', query: {}, packageName: 'index.js', packageVersion: 'latest', diff --git a/yarn.lock b/yarn.lock index e3bad96..6abb441 100644 --- a/yarn.lock +++ b/yarn.lock @@ -919,6 +919,10 @@ base64-js@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" +base64url@2.0.0, base64url@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" + basic-auth@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-1.1.0.tgz#45221ee429f7ee1e5035be3f51533f1cdfd29884" @@ -957,6 +961,21 @@ bluebird@^3.4.6: version "3.5.0" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" +body-parser@^1.18.2: + version "1.18.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454" + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.1" + http-errors "~1.6.2" + iconv-lite "0.4.19" + on-finished "~2.3.0" + qs "6.5.1" + raw-body "2.3.2" + type-is "~1.6.15" + boolbase@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -1013,6 +1032,10 @@ bser@1.0.2: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + buffer@^4.9.0: version "4.9.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" @@ -1037,6 +1060,10 @@ bytes@2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.5.0.tgz#4c9423ea2d252c270c41b2bdefeff9bb6b62c06a" +bytes@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" + caller-path@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" @@ -1337,6 +1364,10 @@ content-type@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.2.tgz#b7d113aee7a8dd27bd21133c4dc2529df1721eed" +content-type@~1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" + convert-source-map@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" @@ -1566,6 +1597,12 @@ debug@2.6.8, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.6.0, debug@^2.6. dependencies: ms "2.0.0" +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + dependencies: + ms "2.0.0" + decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -1721,6 +1758,13 @@ ecc-jsbn@~0.1.1: dependencies: jsbn "~0.1.0" +ecdsa-sig-formatter@1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.9.tgz#4bc926274ec3b5abb5016e7e1d60921ac262b2a1" + dependencies: + base64url "^2.0.0" + safe-buffer "^5.0.1" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -2589,7 +2633,7 @@ htmlparser2@~3.3.0: domutils "1.1" readable-stream "1.0" -http-errors@~1.6.1, http-errors@~1.6.2: +http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" dependencies: @@ -2630,6 +2674,10 @@ iconv-lite@0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" +iconv-lite@0.4.19: + version "0.4.19" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" + iconv-lite@~0.4.13: version "0.4.18" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" @@ -3262,6 +3310,21 @@ jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" +jsonwebtoken@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.1.0.tgz#c6397cd2e5fd583d65c007a83dc7bb78e6982b83" + dependencies: + jws "^3.1.4" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.0.0" + xtend "^4.0.1" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -3275,6 +3338,23 @@ jsx-ast-utils@^1.0.0, jsx-ast-utils@^1.3.1: version "1.4.1" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz#3867213e8dd79bf1e8f2300c0cfc1efb182c0df1" +jwa@^1.1.4: + version "1.1.5" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5" + dependencies: + base64url "2.0.0" + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.9" + safe-buffer "^5.0.1" + +jws@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.1.4.tgz#f9e8b9338e8a847277d6444b1464f61880e050a2" + dependencies: + base64url "^2.0.0" + jwa "^1.1.4" + safe-buffer "^5.0.1" + kind-of@^3.0.2: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -3406,6 +3486,10 @@ lodash.defaults@^4.0.1: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" @@ -3414,6 +3498,26 @@ lodash.isarray@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + lodash.keys@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" @@ -3426,6 +3530,10 @@ lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + lodash.pickby@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.pickby/-/lodash.pickby-4.6.0.tgz#7dea21d8c18d7703a27c704c15d3b84a67e33aff" @@ -3572,6 +3680,10 @@ mime@^1.3.4, mime@^1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/mime/-/mime-1.3.6.tgz#591d84d3653a6b0b4a3b9df8de5aa8108e72e5e0" +mime@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" + min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -3618,7 +3730,7 @@ ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" -ms@2.0.0: +ms@2.0.0, ms@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -3672,6 +3784,10 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" +node-forge@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -4401,6 +4517,10 @@ qs@6.5.0, qs@^6.4.0: version "6.5.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.0.tgz#8d04954d364def3efc55b5a0793e1e2c8b1e6e49" +qs@6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" + qs@~6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" @@ -4445,6 +4565,15 @@ range-parser@^1.0.3, range-parser@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" +raw-body@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" + dependencies: + bytes "3.0.0" + http-errors "1.6.2" + iconv-lite "0.4.19" + unpipe "1.0.0" + rc@^1.1.7: version "1.2.1" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95" @@ -5389,7 +5518,7 @@ uniqs@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" -unpipe@~1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"