From 1204fdf99a99dd6eac518cceee10826ea8094641 Mon Sep 17 00:00:00 2001 From: Olivier MICHAUD Date: Mon, 17 May 2021 21:21:08 +0200 Subject: [PATCH] Add a route to kill tunnel if open with same JWT token --- lib/Client.js | 19 ++++++++++++++- lib/ClientManager.js | 3 ++- lib/ClientManager.test.js | 16 ++++++------- server.js | 42 +++++++++++++++++++++++++++++++-- server.test.js | 49 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 12 deletions(-) diff --git a/lib/Client.js b/lib/Client.js index 369eb07..bca0c5a 100644 --- a/lib/Client.js +++ b/lib/Client.js @@ -2,6 +2,7 @@ import http from 'http'; import Debug from 'debug'; import pump from 'pump'; import EventEmitter from 'events'; +import jwt from 'jsonwebtoken'; // A client encapsulates req/res handling using an agent // @@ -13,6 +14,7 @@ class Client extends EventEmitter { const agent = this.agent = options.agent; const id = this.id = options.id; + this.securityToken = options.securityToken; this.debug = Debug(`lt:Client[${this.id}]`); @@ -45,6 +47,21 @@ class Client extends EventEmitter { }); } + isSecurityTokenEqual(securityToken) { + const decodeJWT = (token) => { + return jwt.decode(token.replace(/Bearer /, ''), {complete: false}); + } + if (this.securityToken === null) { + return false; + } + try{ + return decodeJWT(this.securityToken).name === decodeJWT(securityToken).name; + }catch(error){ + this.debug('error with jwt %s %s', securityToken, error); + return false; + } + } + stats() { return this.agent.stats(); } @@ -129,4 +146,4 @@ class Client extends EventEmitter { } } -export default Client; \ No newline at end of file +export default Client; diff --git a/lib/ClientManager.js b/lib/ClientManager.js index 6d97265..53a3250 100644 --- a/lib/ClientManager.js +++ b/lib/ClientManager.js @@ -30,7 +30,7 @@ class ClientManager { // create a new tunnel with `id` // if the id is already used, a random id is assigned // if the tunnel could not be created, throws an error - async newClient(id) { + async newClient(id, securityToken) { const clients = this.clients; const stats = this.stats; @@ -49,6 +49,7 @@ class ClientManager { const client = new Client({ id, agent, + securityToken }); // add to clients map immediately diff --git a/lib/ClientManager.test.js b/lib/ClientManager.test.js index d85abe5..852be61 100644 --- a/lib/ClientManager.test.js +++ b/lib/ClientManager.test.js @@ -11,22 +11,22 @@ describe('ClientManager', () => { it('should create a new client with random id', async () => { const manager = new ClientManager(); - const client = await manager.newClient(); + const client = await manager.newClient(null, null); assert(manager.hasClient(client.id)); manager.removeClient(client.id); }); it('should create a new client with id', async () => { const manager = new ClientManager(); - const client = await manager.newClient('foobar'); + const client = await manager.newClient('foobar', null); assert(manager.hasClient('foobar')); manager.removeClient('foobar'); }); it('should create a new client with random id if previous exists', async () => { const manager = new ClientManager(); - const clientA = await manager.newClient('foobar'); - const clientB = await manager.newClient('foobar'); + const clientA = await manager.newClient('foobar', null); + const clientB = await manager.newClient('foobar', null); assert(clientA.id, 'foobar'); assert(manager.hasClient(clientB.id)); assert(clientB.id != clientA.id); @@ -36,7 +36,7 @@ describe('ClientManager', () => { it('should remove client once it goes offline', async () => { const manager = new ClientManager(); - const client = await manager.newClient('foobar'); + const client = await manager.newClient('foobar', null); const socket = await new Promise((resolve) => { const netClient = net.createConnection({ port: client.port }, () => { @@ -57,8 +57,8 @@ describe('ClientManager', () => { it('should remove correct client once it goes offline', async () => { const manager = new ClientManager(); - const clientFoo = await manager.newClient('foo'); - const clientBar = await manager.newClient('bar'); + const clientFoo = await manager.newClient('foo', null); + const clientBar = await manager.newClient('bar', null); const socket = await new Promise((resolve) => { const netClient = net.createConnection({ port: clientFoo.port }, () => { @@ -80,7 +80,7 @@ describe('ClientManager', () => { it('should remove clients if they do not connect within 5 seconds', async () => { const manager = new ClientManager(); - const clientFoo = await manager.newClient('foo'); + const clientFoo = await manager.newClient('foo', null); assert(manager.hasClient('foo')); // wait past grace period (1s) diff --git a/server.js b/server.js index 9089132..b5fb32e 100644 --- a/server.js +++ b/server.js @@ -61,6 +61,44 @@ export default function(opt) { }; }); + router.get('/api/tunnels/:id/kill', async (ctx, next) => { + const clientId = ctx.params.id; + if (!opt.jwt_shared_secret){ + debug('disconnecting client with id %s, error: jwt_shared_secret is not used', clientId); + ctx.throw(403, { + success: false, + message: 'jwt_shared_secret is not used' + }); + return; + } + + if (!manager.hasClient(clientId)) { + debug('disconnecting client with id %s, error: client is not connected', clientId); + ctx.throw(404, { + success: false, + message: `client with id ${clientId} is not connected` + }); + } + + const securityToken = ctx.request.headers.authorization; + if (!manager.getClient(clientId).isSecurityTokenEqual(securityToken)) { + debug('disconnecting client with id %s, error: securityToken is not equal ', clientId); + ctx.throw(403, { + success: false, + message: `client with id ${clientId} has not the same securityToken than ${securityToken}` + }); + } + + debug('disconnecting client with id %s', clientId); + manager.removeClient(clientId); + + ctx.statusCode = 200; + ctx.body = { + success: true, + message: `client with id ${clientId} is disconected` + }; + }); + app.use(router.routes()); app.use(router.allowedMethods()); @@ -78,7 +116,7 @@ export default function(opt) { if (isNewClientRequest) { const reqId = hri.random(); debug('making new client with id %s', reqId); - const info = await manager.newClient(reqId); + const info = await manager.newClient(reqId, opt.jwt_shared_secret ? ctx.request.headers.authorization : null); const url = schema + '://' + info.id + '.' + ctx.request.host; info.url = url; @@ -116,7 +154,7 @@ export default function(opt) { } debug('making new client with id %s', reqId); - const info = await manager.newClient(reqId); + const info = await manager.newClient(reqId, opt.jwt_shared_secret ? ctx.request.headers.authorization : null); const url = schema + '://' + info.id + '.' + ctx.request.host; info.url = url; diff --git a/server.test.js b/server.test.js index 8b5bc2f..0d3bb02 100644 --- a/server.test.js +++ b/server.test.js @@ -131,4 +131,53 @@ describe('Server', () => { await new Promise(resolve => server.close(resolve)); }); + + it('should not support the /api/tunnels/:id/kill endpoint if jwt authorization is not enable on server', async () => { + const server = createServer(); + await new Promise(resolve => server.listen(resolve)); + + const res = await request(server).get('/api/tunnels/foobar-test/kill'); + assert.equal(res.statusCode, 403); + assert.equal(res.text, 'jwt_shared_secret is not used'); + + await new Promise(resolve => server.close(resolve)); + }); + + it('should throw error when calling /api/tunnels/:id/kill endpoint if id does not exists', async () => { + const server = createServer({jwt_shared_secret: 'thekey'}); + await new Promise(resolve => server.listen(resolve)); + + { + const jwtoken = jwt.sign({ + name: 'bar' + }, 'thekey'); + await request(server).get('/foobar-test').set('Authorization', `Bearer ${jwtoken}`); + // no such tunnel yet + const res = await request(server).get('/api/tunnels/foobar-test2/kill').set('Authorization', `Bearer ${jwtoken}`); + assert.equal(res.statusCode, 404); + assert.equal(res.text, 'client with id foobar-test2 is not connected'); + } + + await new Promise(resolve => server.close(resolve)); + }); + + it('should disconnect client when calling /api/tunnels/:id/kill endpoint', async () => { + const server = createServer({jwt_shared_secret: 'thekey'}); + await new Promise(resolve => server.listen(resolve)); + + { + const jwtoken = jwt.sign({ + name: 'bar' + }, 'thekey'); + await request(server).get('/foobar-test').set('Authorization', `Bearer ${jwtoken}`); + + const res = await request(server).get('/api/tunnels/foobar-test/kill').set('Authorization', `Bearer ${jwtoken}`); + assert.equal(res.statusCode, 200); + assert.equal(res.text, '{"success":true,"message":"client with id foobar-test is disconected"}'); + const statusResult = await request(server).get('/api/tunnels/foobar-test/status').set('Authorization', `Bearer ${jwtoken}`); + assert.equal(statusResult.text, 'Not Found'); + } + + await new Promise(resolve => server.close(resolve)); + }); });