From 94980f72ead41c8b59cb17699d827ff094de25a4 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 12 Jul 2017 20:44:59 -0400 Subject: [PATCH 1/4] Support client certificates via X-SSL-Cert header. The WebID-TLS implementation assumed an end-to-end TLS connection from the client to the server, so reverse proxies were not possible. With this commit, the reverse proxy can terminate the TLS connection and pass the client certificate through the X-SSL-Cert HTTP header. --- .travis.yml | 14 ++- lib/api/authn/webid-tls.js | 49 ++++++++++- package.json | 3 +- test/integration/acl-tls.js | 165 ++++++++++++++++++++++++++++-------- 4 files changed, 190 insertions(+), 41 deletions(-) diff --git a/.travis.yml b/.travis.yml index 32e58f9eb..70eb05f5e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,20 @@ sudo: false language: node_js node_js: - "6.0" +env: + - CXX=g++-4.8 -cache: - directories: - - node_modules addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 hosts: - nic.localhost - tim.localhost - nicola.localhost + +cache: + directories: + - node_modules diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index c63012407..128b08f04 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,5 +1,8 @@ var webid = require('webid/tls') var debug = require('../../debug').authentication +var x509 = require('x509') + +const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m function authenticate () { return handler @@ -13,10 +16,9 @@ function handler (req, res, next) { return next() } - var certificate = req.connection.getPeerCertificate() - // Certificate is empty? skip - if (certificate === null || Object.keys(certificate).length === 0) { - debug('No client certificate found in the request. Did the user click on a cert?') + // No certificate? skip + const certificate = getCertificateViaTLS(req) || getCertificateViaHeader(req) + if (!certificate) { setEmptySession(req) return next() } @@ -36,6 +38,45 @@ function handler (req, res, next) { }) } +// Tries to obtain a client certificate retrieved through the TLS handshake +function getCertificateViaTLS (req) { + const certificate = req.connection.getPeerCertificate() + if (certificate !== null && Object.keys(certificate).length > 0) { + return certificate + } + debug('No peer certificate received during TLS handshake.') +} + +// Tries to obtain a client certificate retrieved through the X-SSL-Cert header +function getCertificateViaHeader (req) { + // Try to retrieve the certificate from the header + const header = req.headers['x-ssl-cert'] + if (!header) { + return debug('No certificate received through the X-SSL-Cert header.') + } + // The certificate's newlines have been replaced by tabs + // in order to fit in an HTTP header (NGINX does this automatically) + const rawCertificate = header.replace(/\t/g, '\n') + + // Ensure the header contains a valid certificate + // (x509 unsafely interprets it as a file path otherwise) + if (!CERTIFICATE_MATCHER.test(rawCertificate)) { + return debug('Invalid value for the X-SSL-Cert header.') + } + + // Parse and convert the certificate to the format the webid library expects + try { + const { publicKey, extensions } = x509.parseCert(rawCertificate) + return { + modulus: publicKey.n, + exponent: '0x' + parseInt(publicKey.e, 10).toString(16), + subjectaltname: extensions && extensions.subjectAlternativeName + } + } catch (error) { + debug('Invalid certificate received through the X-SSL-Cert header.') + } +} + function setEmptySession (req) { req.session.userId = '' req.session.identified = false diff --git a/package.json b/package.json index 62ba3a954..89ab6a693 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "uuid": "^3.0.0", "valid-url": "^1.0.9", "vhost": "^3.0.2", - "webid": "^0.3.7" + "webid": "^0.3.7", + "x509": "^0.3.2" }, "devDependencies": { "chai": "^3.5.0", diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index 4cf201707..d6bceeb81 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -18,12 +18,43 @@ var rm = require('../test-utils').rm var ldnode = require('../../index') var ns = require('solid-namespace')($rdf) -describe('ACL HTTP', function () { +var address = 'https://localhost:3456/test/' +let rootPath = path.join(__dirname, '../resources') + +var aclExtension = '.acl' +var metaExtension = '.meta' + +var testDir = 'acl-tls/testDir' +var testDirAclFile = testDir + '/' + aclExtension +var testDirMetaFile = testDir + '/' + metaExtension + +var abcFile = testDir + '/abc.ttl' +var abcAclFile = abcFile + aclExtension + +var globFile = testDir + '/*' + +var groupFile = testDir + '/group' + +var origin1 = 'http://example.org/' +var origin2 = 'http://example.com/' + +var user1 = 'https://user1.databox.me/profile/card#me' +var user2 = 'https://user2.databox.me/profile/card#me' +var userCredentials = { + user1: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) + }, + user2: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) + } +} + +describe('ACL with WebID+TLS', function () { this.timeout(10000) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - var address = 'https://localhost:3456/test/' - let rootPath = path.join(__dirname, '../resources') var ldpHttpsServer var ldp = ldnode.createServer({ mount: '/test', @@ -45,36 +76,6 @@ describe('ACL HTTP', function () { fs.removeSync(path.join(rootPath, 'index.html.acl')) }) - var aclExtension = '.acl' - var metaExtension = '.meta' - - var testDir = 'acl-tls/testDir' - var testDirAclFile = testDir + '/' + aclExtension - var testDirMetaFile = testDir + '/' + metaExtension - - var abcFile = testDir + '/abc.ttl' - var abcAclFile = abcFile + aclExtension - - var globFile = testDir + '/*' - - var groupFile = testDir + '/group' - - var origin1 = 'http://example.org/' - var origin2 = 'http://example.com/' - - var user1 = 'https://user1.databox.me/profile/card#me' - var user2 = 'https://user2.databox.me/profile/card#me' - var userCredentials = { - user1: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - }, - user2: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - } - } - function createOptions (path, user) { var options = { url: address + path, @@ -971,3 +972,101 @@ describe('ACL HTTP', function () { }) }) }) + +describe('ACL with WebID through X-SSL-Cert', function () { + this.timeout(10000) + + var ldpHttpsServer + before(function (done) { + const ldp = ldnode.createServer({ + mount: '/test', + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + strictOrigin: true, + auth: 'tls' + }) + ldpHttpsServer = ldp.listen(3456, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(rootPath, 'index.html')) + fs.removeSync(path.join(rootPath, 'index.html.acl')) + }) + + function prepareRequest (certHeader, setResponse) { + return done => { + const options = { + url: address + '/acl-tls/write-acl/.acl', + headers: { 'X-SSL-Cert': certHeader } + } + request(options, function (error, response) { + setResponse(response) + done(error) + }) + } + } + + describe('without certificate', function () { + var response + before(prepareRequest('', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with a valid certificate', function () { + // Escape certificate for usage in HTTP header + const escapedCert = userCredentials.user1.cert.toString() + .replace(/\n/g, '\t') + + var response + before(prepareRequest(escapedCert, res => { response = res })) + + it('should return 200', function () { + assert.propertyVal(response, 'statusCode', 200) + }) + + it('should set the User header', function () { + assert.propertyVal(response.headers, 'user', 'https://user1.databox.me/profile/card#me') + }) + }) + + describe('with a local filename as certificate', function () { + const certFile = path.join(__dirname, '../keys/user1-cert.pem') + + var response + before(prepareRequest(certFile, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate value', function () { + var response + before(prepareRequest('xyz', res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) + + describe('with an invalid certificate', function () { + const invalidCert = +`-----BEGIN CERTIFICATE----- +ABCDEF +-----END CERTIFICATE-----` + .replace(/\n/g, '\t') + + var response + before(prepareRequest(invalidCert, res => { response = res })) + + it('should return 401', function () { + assert.propertyVal(response, 'statusCode', 401) + }) + }) +}) From 56a0088540c4ae3bcfea936d282ddd796e08c654 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Wed, 12 Jul 2017 22:38:10 -0400 Subject: [PATCH 2/4] Make x509 dependency optional. This is a native module, which might not compile on all platforms. Furthermore, it is only needed for header-based WebID auth. --- lib/api/authn/webid-tls.js | 3 ++- package.json | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 128b08f04..9ebc65918 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -1,6 +1,6 @@ var webid = require('webid/tls') var debug = require('../../debug').authentication -var x509 = require('x509') +var x509 // optional dependency, load lazily const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m @@ -65,6 +65,7 @@ function getCertificateViaHeader (req) { } // Parse and convert the certificate to the format the webid library expects + if (!x509) x509 = require('x509') try { const { publicKey, extensions } = x509.parseCert(rawCertificate) return { diff --git a/package.json b/package.json index 89ab6a693..cbbcedfae 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,9 @@ "uuid": "^3.0.0", "valid-url": "^1.0.9", "vhost": "^3.0.2", - "webid": "^0.3.7", + "webid": "^0.3.7" + }, + "optionalDependencies": { "x509": "^0.3.2" }, "devDependencies": { From 85d3d17534d1a0981b2131e177e9a5512ead2fe6 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 09:58:04 -0400 Subject: [PATCH 3/4] Add acceptCertificateHeader option. --- bin/lib/options.js | 6 ++++++ lib/api/authn/webid-tls.js | 3 +++ lib/create-app.js | 1 + test/integration/acl-tls.js | 3 ++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bin/lib/options.js b/bin/lib/options.js index 1e882581c..96bd66732 100644 --- a/bin/lib/options.js +++ b/bin/lib/options.js @@ -75,6 +75,12 @@ module.exports = [ return answers.webid } }, + { + name: 'acceptCertificateHeader', + question: 'Accept client certificates through the X-SSL-Cert header (for reverse proxies)', + default: false, + prompt: false + }, { name: 'useOwner', question: 'Do you already have a WebID?', diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index 9ebc65918..a690effa1 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -49,6 +49,9 @@ function getCertificateViaTLS (req) { // Tries to obtain a client certificate retrieved through the X-SSL-Cert header function getCertificateViaHeader (req) { + // Only allow the X-SSL-Cert header if explicitly enabled + if (!req.app.locals.acceptCertificateHeader) return + // Try to retrieve the certificate from the header const header = req.headers['x-ssl-cert'] if (!header) { diff --git a/lib/create-app.js b/lib/create-app.js index 50a06fdab..fea9d03a0 100644 --- a/lib/create-app.js +++ b/lib/create-app.js @@ -187,6 +187,7 @@ function initAuthentication (argv, app) { case 'tls': // Enforce authentication with WebID-TLS on all LDP routes app.use('/', API.tls.authenticate()) + app.locals.acceptCertificateHeader = argv.acceptCertificateHeader break case 'oidc': let oidc = OidcManager.fromServerConfig(argv) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index d6bceeb81..a0e032a1c 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -985,7 +985,8 @@ describe('ACL with WebID through X-SSL-Cert', function () { sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, strictOrigin: true, - auth: 'tls' + auth: 'tls', + acceptCertificateHeader: true }) ldpHttpsServer = ldp.listen(3456, done) }) From d32842307911e9fcc3df034bfcfaedb939c94071 Mon Sep 17 00:00:00 2001 From: Ruben Verborgh Date: Thu, 13 Jul 2017 09:59:45 -0400 Subject: [PATCH 4/4] WebID through header doesn't require TLS. --- lib/api/authn/webid-tls.js | 5 +++-- lib/create-server.js | 10 +++++++--- test/integration/acl-tls.js | 5 +---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/api/authn/webid-tls.js b/lib/api/authn/webid-tls.js index a690effa1..a8719115a 100644 --- a/lib/api/authn/webid-tls.js +++ b/lib/api/authn/webid-tls.js @@ -40,8 +40,9 @@ function handler (req, res, next) { // Tries to obtain a client certificate retrieved through the TLS handshake function getCertificateViaTLS (req) { - const certificate = req.connection.getPeerCertificate() - if (certificate !== null && Object.keys(certificate).length > 0) { + const certificate = req.connection.getPeerCertificate && + req.connection.getPeerCertificate() + if (certificate && Object.keys(certificate).length > 0) { return certificate } debug('No peer certificate received during TLS handshake.') diff --git a/lib/create-server.js b/lib/create-server.js index 6e4c0b225..049b5f195 100644 --- a/lib/create-server.js +++ b/lib/create-server.js @@ -12,7 +12,7 @@ function createServer (argv, app) { argv = argv || {} app = app || express() var ldpApp = createApp(argv) - var ldp = ldpApp.locals.ldp + var ldp = ldpApp.locals.ldp || {} var mount = argv.mount || '/' // Removing ending '/' if (mount.length > 1 && @@ -21,9 +21,13 @@ function createServer (argv, app) { } app.use(mount, ldpApp) debug.settings('Base URL (--mount): ' + mount) - var server = http.createServer(app) - if (ldp && (ldp.webid || ldp.idp || argv.sslKey || argv.sslCert)) { + var server + var needsTLS = argv.sslKey || argv.sslCert || + (ldp.webid || ldp.idp) && !argv.acceptCertificateHeader + if (!needsTLS) { + server = http.createServer(app) + } else { debug.settings('SSL Private Key path: ' + argv.sslKey) debug.settings('SSL Certificate path: ' + argv.sslCert) diff --git a/test/integration/acl-tls.js b/test/integration/acl-tls.js index a0e032a1c..2220c3a01 100644 --- a/test/integration/acl-tls.js +++ b/test/integration/acl-tls.js @@ -981,10 +981,7 @@ describe('ACL with WebID through X-SSL-Cert', function () { const ldp = ldnode.createServer({ mount: '/test', root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), webid: true, - strictOrigin: true, auth: 'tls', acceptCertificateHeader: true }) @@ -1000,7 +997,7 @@ describe('ACL with WebID through X-SSL-Cert', function () { function prepareRequest (certHeader, setResponse) { return done => { const options = { - url: address + '/acl-tls/write-acl/.acl', + url: address.replace('https', 'http') + '/acl-tls/write-acl/.acl', headers: { 'X-SSL-Cert': certHeader } } request(options, function (error, response) {