Add auth and blacklist APIs

This commit is contained in:
MICHAEL JACKSON
2017-11-11 12:18:13 -08:00
parent cc70428986
commit 0e1f26849b
35 changed files with 1166 additions and 339 deletions

118
server/AuthAPI.js Normal file
View File

@ -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
}

71
server/BlacklistAPI.js Normal file
View File

@ -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
}

View File

@ -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"
]
}

View File

@ -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 = {

View File

@ -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()
})
})
})
})
})

View File

@ -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()
})
})
})
})
})
})

View File

@ -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()
})
})
})
})
})
})

View File

@ -0,0 +1,7 @@
const BlacklistAPI = require('../../BlacklistAPI')
function withBlacklist(blacklist, callback) {
return Promise.all(blacklist.map(BlacklistAPI.addPackage)).then(callback)
}
module.exports = withBlacklist

View File

@ -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

View File

@ -0,0 +1,7 @@
const AuthAPI = require('../../AuthAPI')
function withToken(scopes, callback) {
AuthAPI.createToken(scopes).then(callback)
}
module.exports = withToken

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
function showAuth(req, res) {
res.send({ auth: req.user })
}
module.exports = showAuth

View File

@ -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

View File

@ -0,0 +1,7 @@
const AuthAPI = require('../AuthAPI')
function showPublicKey(req, res) {
res.type('text').send(AuthAPI.getPublicKey())
}
module.exports = showPublicKey

View File

@ -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

View File

@ -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
}

View File

@ -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()
})
})
})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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',