Skip to content

Commit 180a114

Browse files
RubenVerborghdmitrizagidulin
authored andcommitted
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.
1 parent c92c6ad commit 180a114

4 files changed

Lines changed: 190 additions & 41 deletions

File tree

.travis.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ sudo: false
22
language: node_js
33
node_js:
44
- "6.0"
5+
env:
6+
- CXX=g++-4.8
57

6-
cache:
7-
directories:
8-
- node_modules
98
addons:
9+
apt:
10+
sources:
11+
- ubuntu-toolchain-r-test
12+
packages:
13+
- g++-4.8
1014
hosts:
1115
- nic.localhost
1216
- tim.localhost
1317
- nicola.localhost
18+
19+
cache:
20+
directories:
21+
- node_modules

lib/api/authn/webid-tls.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
var webid = require('webid/tls')
22
var debug = require('../../debug').authentication
3+
var x509 = require('x509')
4+
5+
const CERTIFICATE_MATCHER = /^-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/=]+\n)+-----END CERTIFICATE-----$/m
36

47
function authenticate () {
58
return handler
@@ -13,10 +16,9 @@ function handler (req, res, next) {
1316
return next()
1417
}
1518

16-
var certificate = req.connection.getPeerCertificate()
17-
// Certificate is empty? skip
18-
if (certificate === null || Object.keys(certificate).length === 0) {
19-
debug('No client certificate found in the request. Did the user click on a cert?')
19+
// No certificate? skip
20+
const certificate = getCertificateViaTLS(req) || getCertificateViaHeader(req)
21+
if (!certificate) {
2022
setEmptySession(req)
2123
return next()
2224
}
@@ -36,6 +38,45 @@ function handler (req, res, next) {
3638
})
3739
}
3840

41+
// Tries to obtain a client certificate retrieved through the TLS handshake
42+
function getCertificateViaTLS (req) {
43+
const certificate = req.connection.getPeerCertificate()
44+
if (certificate !== null && Object.keys(certificate).length > 0) {
45+
return certificate
46+
}
47+
debug('No peer certificate received during TLS handshake.')
48+
}
49+
50+
// Tries to obtain a client certificate retrieved through the X-SSL-Cert header
51+
function getCertificateViaHeader (req) {
52+
// Try to retrieve the certificate from the header
53+
const header = req.headers['x-ssl-cert']
54+
if (!header) {
55+
return debug('No certificate received through the X-SSL-Cert header.')
56+
}
57+
// The certificate's newlines have been replaced by tabs
58+
// in order to fit in an HTTP header (NGINX does this automatically)
59+
const rawCertificate = header.replace(/\t/g, '\n')
60+
61+
// Ensure the header contains a valid certificate
62+
// (x509 unsafely interprets it as a file path otherwise)
63+
if (!CERTIFICATE_MATCHER.test(rawCertificate)) {
64+
return debug('Invalid value for the X-SSL-Cert header.')
65+
}
66+
67+
// Parse and convert the certificate to the format the webid library expects
68+
try {
69+
const { publicKey, extensions } = x509.parseCert(rawCertificate)
70+
return {
71+
modulus: publicKey.n,
72+
exponent: '0x' + parseInt(publicKey.e, 10).toString(16),
73+
subjectaltname: extensions && extensions.subjectAlternativeName
74+
}
75+
} catch (error) {
76+
debug('Invalid certificate received through the X-SSL-Cert header.')
77+
}
78+
}
79+
3980
function setEmptySession (req) {
4081
req.session.userId = ''
4182
req.session.identified = false

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@
7373
"uuid": "^3.0.0",
7474
"valid-url": "^1.0.9",
7575
"vhost": "^3.0.2",
76-
"webid": "^0.3.7"
76+
"webid": "^0.3.7",
77+
"x509": "^0.3.2"
7778
},
7879
"devDependencies": {
7980
"chai": "^3.5.0",

test/integration/acl-tls.js

Lines changed: 132 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,43 @@ var rm = require('../test-utils').rm
1818
var ldnode = require('../../index')
1919
var ns = require('solid-namespace')($rdf)
2020

21-
describe('ACL HTTP', function () {
21+
var address = 'https://localhost:3456/test/'
22+
let rootPath = path.join(__dirname, '../resources')
23+
24+
var aclExtension = '.acl'
25+
var metaExtension = '.meta'
26+
27+
var testDir = 'acl-tls/testDir'
28+
var testDirAclFile = testDir + '/' + aclExtension
29+
var testDirMetaFile = testDir + '/' + metaExtension
30+
31+
var abcFile = testDir + '/abc.ttl'
32+
var abcAclFile = abcFile + aclExtension
33+
34+
var globFile = testDir + '/*'
35+
36+
var groupFile = testDir + '/group'
37+
38+
var origin1 = 'http://example.org/'
39+
var origin2 = 'http://example.com/'
40+
41+
var user1 = 'https://user1.databox.me/profile/card#me'
42+
var user2 = 'https://user2.databox.me/profile/card#me'
43+
var userCredentials = {
44+
user1: {
45+
cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')),
46+
key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem'))
47+
},
48+
user2: {
49+
cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')),
50+
key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem'))
51+
}
52+
}
53+
54+
describe('ACL with WebID+TLS', function () {
2255
this.timeout(10000)
2356
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
2457

25-
var address = 'https://localhost:3456/test/'
26-
let rootPath = path.join(__dirname, '../resources')
2758
var ldpHttpsServer
2859
var ldp = ldnode.createServer({
2960
mount: '/test',
@@ -45,36 +76,6 @@ describe('ACL HTTP', function () {
4576
fs.removeSync(path.join(rootPath, 'index.html.acl'))
4677
})
4778

48-
var aclExtension = '.acl'
49-
var metaExtension = '.meta'
50-
51-
var testDir = 'acl-tls/testDir'
52-
var testDirAclFile = testDir + '/' + aclExtension
53-
var testDirMetaFile = testDir + '/' + metaExtension
54-
55-
var abcFile = testDir + '/abc.ttl'
56-
var abcAclFile = abcFile + aclExtension
57-
58-
var globFile = testDir + '/*'
59-
60-
var groupFile = testDir + '/group'
61-
62-
var origin1 = 'http://example.org/'
63-
var origin2 = 'http://example.com/'
64-
65-
var user1 = 'https://user1.databox.me/profile/card#me'
66-
var user2 = 'https://user2.databox.me/profile/card#me'
67-
var userCredentials = {
68-
user1: {
69-
cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')),
70-
key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem'))
71-
},
72-
user2: {
73-
cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')),
74-
key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem'))
75-
}
76-
}
77-
7879
function createOptions (path, user) {
7980
var options = {
8081
url: address + path,
@@ -971,3 +972,101 @@ describe('ACL HTTP', function () {
971972
})
972973
})
973974
})
975+
976+
describe('ACL with WebID through X-SSL-Cert', function () {
977+
this.timeout(10000)
978+
979+
var ldpHttpsServer
980+
before(function (done) {
981+
const ldp = ldnode.createServer({
982+
mount: '/test',
983+
root: rootPath,
984+
sslKey: path.join(__dirname, '../keys/key.pem'),
985+
sslCert: path.join(__dirname, '../keys/cert.pem'),
986+
webid: true,
987+
strictOrigin: true,
988+
auth: 'tls'
989+
})
990+
ldpHttpsServer = ldp.listen(3456, done)
991+
})
992+
993+
after(function () {
994+
if (ldpHttpsServer) ldpHttpsServer.close()
995+
fs.removeSync(path.join(rootPath, 'index.html'))
996+
fs.removeSync(path.join(rootPath, 'index.html.acl'))
997+
})
998+
999+
function prepareRequest (certHeader, setResponse) {
1000+
return done => {
1001+
const options = {
1002+
url: address + '/acl-tls/write-acl/.acl',
1003+
headers: { 'X-SSL-Cert': certHeader }
1004+
}
1005+
request(options, function (error, response) {
1006+
setResponse(response)
1007+
done(error)
1008+
})
1009+
}
1010+
}
1011+
1012+
describe('without certificate', function () {
1013+
var response
1014+
before(prepareRequest('', res => { response = res }))
1015+
1016+
it('should return 401', function () {
1017+
assert.propertyVal(response, 'statusCode', 401)
1018+
})
1019+
})
1020+
1021+
describe('with a valid certificate', function () {
1022+
// Escape certificate for usage in HTTP header
1023+
const escapedCert = userCredentials.user1.cert.toString()
1024+
.replace(/\n/g, '\t')
1025+
1026+
var response
1027+
before(prepareRequest(escapedCert, res => { response = res }))
1028+
1029+
it('should return 200', function () {
1030+
assert.propertyVal(response, 'statusCode', 200)
1031+
})
1032+
1033+
it('should set the User header', function () {
1034+
assert.propertyVal(response.headers, 'user', 'https://user1.databox.me/profile/card#me')
1035+
})
1036+
})
1037+
1038+
describe('with a local filename as certificate', function () {
1039+
const certFile = path.join(__dirname, '../keys/user1-cert.pem')
1040+
1041+
var response
1042+
before(prepareRequest(certFile, res => { response = res }))
1043+
1044+
it('should return 401', function () {
1045+
assert.propertyVal(response, 'statusCode', 401)
1046+
})
1047+
})
1048+
1049+
describe('with an invalid certificate value', function () {
1050+
var response
1051+
before(prepareRequest('xyz', res => { response = res }))
1052+
1053+
it('should return 401', function () {
1054+
assert.propertyVal(response, 'statusCode', 401)
1055+
})
1056+
})
1057+
1058+
describe('with an invalid certificate', function () {
1059+
const invalidCert =
1060+
`-----BEGIN CERTIFICATE-----
1061+
ABCDEF
1062+
-----END CERTIFICATE-----`
1063+
.replace(/\n/g, '\t')
1064+
1065+
var response
1066+
before(prepareRequest(invalidCert, res => { response = res }))
1067+
1068+
it('should return 401', function () {
1069+
assert.propertyVal(response, 'statusCode', 401)
1070+
})
1071+
})
1072+
})

0 commit comments

Comments
 (0)