|
| 1 | +/* global describe, it */ |
| 2 | +'use strict' |
| 3 | + |
| 4 | +const { expect } = require('chai') |
| 5 | +const net = require('net') |
| 6 | +const http = require('http') |
| 7 | + |
| 8 | +describe('Security: buildURL SSRF prevention', () => { |
| 9 | + const { buildURL } = require('../lib/utils') |
| 10 | + |
| 11 | + it('should allow relative paths within base', () => { |
| 12 | + const url = buildURL('/hi', 'http://localhost:3000') |
| 13 | + expect(url.origin).to.equal('http://localhost:3000') |
| 14 | + }) |
| 15 | + |
| 16 | + it('should block absolute URL bypassing base', () => { |
| 17 | + expect(() => buildURL('http://evil.com/admin', 'http://127.0.0.1:3000')) |
| 18 | + .to.throw(/SSRF prevention/) |
| 19 | + }) |
| 20 | + |
| 21 | + it('should block HTTPS absolute URL bypass', () => { |
| 22 | + expect(() => buildURL('https://internal/api', 'http://127.0.0.1:3000')) |
| 23 | + .to.throw(/SSRF prevention/) |
| 24 | + }) |
| 25 | + |
| 26 | + it('should allow absolute URL when no base', () => { |
| 27 | + const url = buildURL('http://target.com/api') |
| 28 | + expect(url.href).to.equal('http://target.com/api') |
| 29 | + }) |
| 30 | + |
| 31 | + it('should sanitize protocol-relative within base', () => { |
| 32 | + const url = buildURL('//evil.com/hi', 'http://localhost') |
| 33 | + expect(url.origin).to.equal('http://localhost') |
| 34 | + }) |
| 35 | +}) |
| 36 | + |
| 37 | +describe('Security: hop-by-hop header stripping', () => { |
| 38 | + const { stripHttp1ConnectionHeaders } = require('../lib/utils') |
| 39 | + |
| 40 | + it('should strip transfer-encoding', () => { |
| 41 | + const h = { 'transfer-encoding': 'gzip, chunked', 'x-custom': 'val' } |
| 42 | + const r = stripHttp1ConnectionHeaders(h) |
| 43 | + expect(r).to.not.have.property('transfer-encoding') |
| 44 | + expect(r).to.have.property('x-custom', 'val') |
| 45 | + }) |
| 46 | + |
| 47 | + it('should strip connection and keep-alive', () => { |
| 48 | + const h = { connection: 'close', 'keep-alive': 't=5', 'x-data': 'ok' } |
| 49 | + const r = stripHttp1ConnectionHeaders(h) |
| 50 | + expect(r).to.not.have.property('connection') |
| 51 | + expect(r).to.not.have.property('keep-alive') |
| 52 | + expect(r).to.have.property('x-data', 'ok') |
| 53 | + }) |
| 54 | + |
| 55 | + it('should strip host header from response', () => { |
| 56 | + const h = { host: 'evil.com', 'content-type': 'text/plain' } |
| 57 | + const r = stripHttp1ConnectionHeaders(h) |
| 58 | + expect(r).to.not.have.property('host') |
| 59 | + expect(r).to.have.property('content-type', 'text/plain') |
| 60 | + }) |
| 61 | +}) |
| 62 | + |
| 63 | +describe('Security: SSRF end-to-end proxy', () => { |
| 64 | + let gateway, service, close, proxy, gHttpServer |
| 65 | + |
| 66 | + it('setup', async () => { |
| 67 | + const fastProxy = require('../index')({ base: 'http://127.0.0.1:3000' }) |
| 68 | + close = fastProxy.close |
| 69 | + proxy = fastProxy.proxy |
| 70 | + gateway = require('restana')() |
| 71 | + gateway.all('/*', (req, res) => proxy(req, res, req.url, {})) |
| 72 | + gHttpServer = await gateway.start(8080) |
| 73 | + service = require('restana')() |
| 74 | + service.get('/service/get', (req, res) => res.send('OK')) |
| 75 | + service.get('/service/evil', (req, res) => { |
| 76 | + res.setHeader('transfer-encoding', 'gzip, chunked') |
| 77 | + res.setHeader('keep-alive', 'timeout=99') |
| 78 | + res.setHeader('x-custom', 'downstream') |
| 79 | + res.end('evil') |
| 80 | + }) |
| 81 | + await service.start(3000) |
| 82 | + }) |
| 83 | + |
| 84 | + it('should block SSRF via absolute-form request', (done) => { |
| 85 | + // Use raw http.createServer instead of restana because |
| 86 | + // restana routes cannot match absolute-form req.url values. |
| 87 | + const fastProxy = require('../index')({ base: 'http://127.0.0.1:3000' }) |
| 88 | + const { proxy, close } = fastProxy |
| 89 | + const server = http.createServer((req, res) => { |
| 90 | + proxy(req, res, req.url, {}) |
| 91 | + }) |
| 92 | + server.listen(0, () => { |
| 93 | + const port = server.address().port |
| 94 | + const c = net.connect(port, '127.0.0.1', () => { |
| 95 | + c.write('GET http://169.254.169.254/latest HTTP/1.1\r\n') |
| 96 | + c.write('Host: 127.0.0.1\r\n') |
| 97 | + c.write('Connection: close\r\n\r\n') |
| 98 | + }) |
| 99 | + let d = '' |
| 100 | + c.on('data', ch => { d += ch.toString() }) |
| 101 | + c.on('end', () => { |
| 102 | + expect(d).to.include('400') |
| 103 | + // 400 status + normal proxy still functional after SSRF attempt |
| 104 | + close() |
| 105 | + server.close() |
| 106 | + done() |
| 107 | + }) |
| 108 | + c.on('error', done) |
| 109 | + }) |
| 110 | + }); it('should strip hop-by-hop headers end-to-end', async () => { |
| 111 | + const res = await require('supertest')(gHttpServer) |
| 112 | + .get('/service/evil') |
| 113 | + .expect(200) |
| 114 | + expect(res.headers['transfer-encoding']).to.not.equal('gzip, chunked') |
| 115 | + expect(res.headers['keep-alive']).to.not.equal('timeout=99') |
| 116 | + expect(res.headers['x-custom']).to.equal('downstream') |
| 117 | + }) |
| 118 | + |
| 119 | + it('teardown', async () => { |
| 120 | + close() |
| 121 | + await gateway.close() |
| 122 | + await service.close() |
| 123 | + }) |
| 124 | +}) |
0 commit comments