From 3ed1f64d43538ebbb073449ec4de6811c1f5978d Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Tue, 28 Feb 2017 21:02:56 +0200 Subject: [PATCH] feat(aio): implement `BuildVerifier` --- .../lib/upload-server/build-verifier.ts | 75 +++++++ .../dockerbuild/scripts-js/package.json | 2 + .../test/upload-server/build-creator.spec.ts | 12 +- .../test/upload-server/build-verifier.spec.ts | 203 ++++++++++++++++++ .../scripts-js/test/upload-server/helpers.ts | 11 + .../dockerbuild/scripts-js/yarn.lock | 91 +++++++- 6 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 aio/aio-builds-setup/dockerbuild/scripts-js/lib/upload-server/build-verifier.ts create mode 100644 aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-verifier.spec.ts create mode 100644 aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/helpers.ts diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/lib/upload-server/build-verifier.ts b/aio/aio-builds-setup/dockerbuild/scripts-js/lib/upload-server/build-verifier.ts new file mode 100644 index 0000000000..c56defc1c3 --- /dev/null +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/lib/upload-server/build-verifier.ts @@ -0,0 +1,75 @@ +// Imports +import * as jwt from 'jsonwebtoken'; +import {GithubPullRequests, PullRequest} from '../common/github-pull-requests'; +import {GithubTeams} from '../common/github-teams'; +import {assertNotMissingOrEmpty} from '../common/utils'; +import {UploadError} from './upload-error'; + +// Interfaces - Types +interface JwtPayload { + slug: string; + 'pull-request': number; +} + +// Classes +export class BuildVerifier { + // Properties - Protected + protected githubPullRequests: GithubPullRequests; + protected githubTeams: GithubTeams; + + // Constructor + constructor(protected secret: string, githubToken: string, protected repoSlug: string, organization: string, + protected allowedTeamSlugs: string[]) { + assertNotMissingOrEmpty('secret', secret); + assertNotMissingOrEmpty('githubToken', githubToken); + assertNotMissingOrEmpty('repoSlug', repoSlug); + assertNotMissingOrEmpty('organization', organization); + assertNotMissingOrEmpty('allowedTeamSlugs', allowedTeamSlugs && allowedTeamSlugs.join('')); + + this.githubPullRequests = new GithubPullRequests(githubToken, repoSlug); + this.githubTeams = new GithubTeams(githubToken, organization); + } + + // Methods - Public + public verify(pr: number, authHeader: string): Promise { + return Promise.resolve(). + then(() => this.extractJwtString(authHeader)). + then(jwtString => this.verifyJwt(pr, jwtString)). + then(jwtPayload => this.fetchPr(jwtPayload['pull-request'])). + then(prInfo => this.verifyPr(prInfo.user.login)). + catch(err => { throw new UploadError(403, `Error while verifying upload for PR ${pr}: ${err}`); }); + } + + // Methods - Protected + protected extractJwtString(input: string): string { + return input.replace(/^token +/i, ''); + } + + protected fetchPr(pr: number): Promise { + return this.githubPullRequests.fetch(pr); + } + + protected verifyJwt(pr: number, token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, this.secret, {issuer: 'Travis CI, GmbH'}, (err, payload) => { + if (err) { + reject(err.message || err); + } else if (payload.slug !== this.repoSlug) { + reject(`jwt slug invalid. expected: ${this.repoSlug}`); + } else if (payload['pull-request'] !== pr) { + reject(`jwt pull-request invalid. expected: ${pr}`); + } else { + resolve(payload); + } + }); + }); + } + + protected verifyPr(username: string): Promise { + const errorMessage = `User '${username}' is not an active member of any of: ` + + `${this.allowedTeamSlugs.join(', ')}`; + + return this.githubTeams.isMemberBySlug(username, this.allowedTeamSlugs). + then(isMember => isMember ? Promise.resolve() : Promise.reject(errorMessage)); + } +} diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/package.json b/aio/aio-builds-setup/dockerbuild/scripts-js/package.json index c7c3a34bfc..bc8ab59c25 100644 --- a/aio/aio-builds-setup/dockerbuild/scripts-js/package.json +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/package.json @@ -19,11 +19,13 @@ "dependencies": { "express": "^4.14.1", "jasmine": "^2.5.3", + "jsonwebtoken": "^7.3.0", "shelljs": "^0.7.6" }, "devDependencies": { "@types/express": "^4.0.35", "@types/jasmine": "^2.5.43", + "@types/jsonwebtoken": "^7.2.0", "@types/node": "^7.0.5", "@types/shelljs": "^0.7.0", "@types/supertest": "^2.0.0", diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-creator.spec.ts b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-creator.spec.ts index 92435cdd97..4d75122c38 100644 --- a/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-creator.spec.ts +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-creator.spec.ts @@ -6,6 +6,7 @@ import * as shell from 'shelljs'; import {BuildCreator} from '../../lib/upload-server/build-creator'; import {CreatedBuildEvent} from '../../lib/upload-server/build-events'; import {UploadError} from '../../lib/upload-server/upload-error'; +import {expectToBeUploadError} from './helpers'; // Tests describe('BuildCreator', () => { @@ -17,17 +18,6 @@ describe('BuildCreator', () => { const shaDir = `${prDir}/${sha}`; let bc: BuildCreator; - // Helpers - const expectToBeUploadError = (actual: UploadError, expStatus?: number, expMessage?: string) => { - expect(actual).toEqual(jasmine.any(UploadError)); - if (expStatus != null) { - expect(actual.status).toBe(expStatus); - } - if (expMessage != null) { - expect(actual.message).toBe(expMessage); - } - }; - beforeEach(() => bc = new BuildCreator(buildsDir)); diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-verifier.spec.ts b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-verifier.spec.ts new file mode 100644 index 0000000000..47f720b3d1 --- /dev/null +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/build-verifier.spec.ts @@ -0,0 +1,203 @@ +// Imports +import * as jwt from 'jsonwebtoken'; +import {GithubPullRequests} from '../../lib/common/github-pull-requests'; +import {GithubTeams} from '../../lib/common/github-teams'; +import {BuildVerifier} from '../../lib/upload-server/build-verifier'; +import {expectToBeUploadError} from './helpers'; + +// Tests +describe('BuildVerifier', () => { + const defaultConfig = { + allowedTeamSlugs: ['team1', 'team2'], + githubToken: 'githubToken', + organization: 'organization', + repoSlug: 'repo/slug', + secret: 'secret', + }; + let bv: BuildVerifier; + + // Helpers + const createBuildVerifier = (partialConfig: Partial = {}) => { + const cfg = {...defaultConfig, ...partialConfig}; + return new BuildVerifier(cfg.secret, cfg.githubToken, cfg.repoSlug, cfg.organization, + cfg.allowedTeamSlugs); + }; + + beforeEach(() => bv = createBuildVerifier()); + + + describe('constructor()', () => { + + ['secret', 'githubToken', 'repoSlug', 'organization', 'allowedTeamSlugs'].forEach(param => { + it(`should throw if '${param}' is missing or empty`, () => { + expect(() => createBuildVerifier({[param]: ''})). + toThrowError(`Missing or empty required parameter '${param}'!`); + }); + }); + + + it('should throw if \'allowedTeamSlugs\' is an empty array', () => { + expect(() => createBuildVerifier({allowedTeamSlugs: []})). + toThrowError('Missing or empty required parameter \'allowedTeamSlugs\'!'); + }); + + }); + + + describe('verify()', () => { + const pr = 9; + const defaultJwt = { + 'exp': Math.floor(Date.now() / 1000) + 30, + 'iat': Math.floor(Date.now() / 1000) - 30, + 'iss': 'Travis CI, GmbH', + 'pull-request': pr, + 'slug': defaultConfig.repoSlug, + }; + let prsFetchSpy: jasmine.Spy; + let teamsIsMemberBySlugSpy: jasmine.Spy; + + // Heleprs + const createAuthHeader = (partialJwt: Partial = {}, secret: string = defaultConfig.secret) => + `Token ${jwt.sign({...defaultJwt, ...partialJwt}, secret)}`; + + beforeEach(() => { + prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch'). + and.returnValue(Promise.resolve({user: {login: 'username'}})); + + teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug'). + and.returnValue(Promise.resolve(true)); + }); + + + it('should return a promise', () => { + expect(bv.verify(pr, createAuthHeader())).toEqual(jasmine.any(Promise)); + }); + + + it('should fail if the authorization header is invalid', done => { + bv.verify(pr, 'foo').catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: jwt malformed'; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should fail if the secret is invalid', done => { + bv.verify(pr, createAuthHeader({}, 'foo')).catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: invalid signature'; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should fail if the issuer is invalid', done => { + bv.verify(pr, createAuthHeader({iss: 'not valid'})).catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: ' + + `jwt issuer invalid. expected: ${defaultJwt.iss}`; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should fail if the token has expired', done => { + bv.verify(pr, createAuthHeader({exp: 0})).catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: jwt expired'; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should fail if the repo slug does not match', done => { + bv.verify(pr, createAuthHeader({slug: 'foo/bar'})).catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: ' + + `jwt slug invalid. expected: ${defaultConfig.repoSlug}`; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should fail if the PR does not match', done => { + bv.verify(pr, createAuthHeader({'pull-request': 1337})).catch(err => { + const errorMessage = 'Error while verifying upload for PR 9: ' + + `jwt pull-request invalid. expected: ${pr}`; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should not fail if the token is valid', done => { + bv.verify(pr, createAuthHeader()).then(done); + }); + + + it('should not fail even if the token has been issued in the future', done => { + const in30s = Math.floor(Date.now() / 1000) + 30; + bv.verify(pr, createAuthHeader({iat: in30s})).then(done); + }); + + + it('should fetch the corresponding PR if the token is valid', done => { + bv.verify(pr, createAuthHeader()).then(() => { + expect(prsFetchSpy).toHaveBeenCalledWith(pr); + done(); + }); + }); + + + it('should fail if fetching the PR errors', done => { + prsFetchSpy.and.callFake(() => Promise.reject('Test')); + bv.verify(pr, createAuthHeader()).catch(err => { + expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`); + done(); + }); + }); + + + it('should verify the PR author\'s membership in the specified teams', done => { + bv.verify(pr, createAuthHeader()).then(() => { + expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']); + done(); + }); + }); + + + it('should fail if verifying membership errors', done => { + teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test')); + bv.verify(pr, createAuthHeader()).catch(err => { + expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`); + done(); + }); + }); + + + it('should fail if the PR author is not a member of the specified teams', done => { + teamsIsMemberBySlugSpy.and.callFake(() => Promise.resolve(false)); + bv.verify(pr, createAuthHeader()).catch(err => { + const errorMessage = `Error while verifying upload for PR ${pr}: ` + + `User 'username' is not an active member of any of: team1, team2`; + + expectToBeUploadError(err, 403, errorMessage); + done(); + }); + }); + + + it('should succeed if everything checks outs', done => { + bv.verify(pr, createAuthHeader()).then(done); + }); + + }); + +}); diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/helpers.ts b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/helpers.ts new file mode 100644 index 0000000000..9213d7c1a6 --- /dev/null +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/test/upload-server/helpers.ts @@ -0,0 +1,11 @@ +import {UploadError} from '../../lib/upload-server/upload-error'; + +export const expectToBeUploadError = (actual: UploadError, status?: number, message?: string) => { + expect(actual).toEqual(jasmine.any(UploadError)); + if (status != null) { + expect(actual.status).toBe(status); + } + if (message != null) { + expect(actual.message).toBe(message); + } +}; diff --git a/aio/aio-builds-setup/dockerbuild/scripts-js/yarn.lock b/aio/aio-builds-setup/dockerbuild/scripts-js/yarn.lock index afa1753520..2374aedd41 100644 --- a/aio/aio-builds-setup/dockerbuild/scripts-js/yarn.lock +++ b/aio/aio-builds-setup/dockerbuild/scripts-js/yarn.lock @@ -21,6 +21,12 @@ dependencies: typescript ">=2.1.4" +"@types/jsonwebtoken@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.0.tgz#0fed32c8501da80ac9839d2d403a65c83d776ffd" + dependencies: + "@types/node" "*" + "@types/mime@*": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-0.0.29.tgz#fbcfd330573b912ef59eeee14602bface630754b" @@ -212,6 +218,10 @@ balanced-match@^0.4.1: version "0.4.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" +base64url@2.0.0, base64url@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64url/-/base64url-2.0.0.tgz#eac16e03ea1438eff9423d69baa36262ed1f70bb" + bcrypt-pbkdf@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" @@ -263,6 +273,10 @@ braces@^1.8.2: preserve "^0.2.0" repeat-element "^1.1.2" +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-shims@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51" @@ -482,13 +496,7 @@ date-fns@^1.23.0: version "1.27.2" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.27.2.tgz#ce82f420bc028356cc661fc55c0494a56a990c9c" -debug@^2.1.1, debug@^2.2.0: - version "2.6.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.1.tgz#79855090ba2c4e3115cc7d8769491d58f0491351" - dependencies: - ms "0.7.2" - -debug@~2.2.0: +debug@^2.1.1, debug@^2.2.0, debug@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" dependencies: @@ -572,6 +580,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" @@ -1320,6 +1335,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" +isemail@1.x.x: + version "1.2.0" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -1348,6 +1367,15 @@ jodid25519@^1.0.0: dependencies: jsbn "~0.1.0" +joi@^6.10.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/joi/-/joi-6.10.1.tgz#4d50c318079122000fe5f16af1ff8e1917b77e06" + dependencies: + hoek "2.x.x" + isemail "1.x.x" + moment "2.x.x" + topo "1.x.x" + js-tokens@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7" @@ -1385,6 +1413,16 @@ jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" +jsonwebtoken@^7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.3.0.tgz#85118d6a70e3fccdf14389f4e7a1c3f9c8a9fbba" + dependencies: + joi "^6.10.1" + jws "^3.1.4" + lodash.once "^4.0.0" + ms "^0.7.1" + xtend "^4.0.1" + jsprim@^1.2.2: version "1.3.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.3.1.tgz#2a7256f70412a29ee3670aaca625994c4dcff252" @@ -1393,6 +1431,23 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.3.6" +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.1.0" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.1.0.tgz#475d698a5e49ff5e53d14e3e732429dc8bf4cf47" @@ -1484,6 +1539,10 @@ lodash.keys@^3.0.0: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" @@ -1564,11 +1623,15 @@ minimist@^1.2.0: dependencies: minimist "0.0.8" +moment@2.x.x: + version "2.17.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.17.1.tgz#fed9506063f36b10f066c8b59a144d7faebe1d82" + ms@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098" -ms@0.7.2: +ms@0.7.2, ms@^0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" @@ -2038,6 +2101,10 @@ rx@2.3.24: version "2.3.24" resolved "https://registry.yarnpkg.com/rx/-/rx-2.3.24.tgz#14f950a4217d7e35daa71bbcbe58eff68ea4b2b7" +safe-buffer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" + semver-diff@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" @@ -2289,6 +2356,12 @@ timed-out@^3.0.0: version "3.1.3" resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217" +topo@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/topo/-/topo-1.1.0.tgz#e9d751615d1bb87dc865db182fa1ca0a5ef536d5" + dependencies: + hoek "2.x.x" + touch@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/touch/-/touch-1.0.0.tgz#449cbe2dbae5a8c8038e30d71fa0ff464947c4de" @@ -2474,6 +2547,6 @@ xdg-basedir@^2.0.0: dependencies: os-homedir "^1.0.0" -xtend@^4.0.0: +xtend@^4.0.0, xtend@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"