Skip to content

Commit f556b0b

Browse files
committed
fix: prevent host header injection by sanitizing userinfo in Host header
1 parent 18e5985 commit f556b0b

3 files changed

Lines changed: 199 additions & 1 deletion

File tree

lib/request.js

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,23 @@ var parseRange = require('range-parser');
2222
var parse = require('parseurl');
2323
var proxyaddr = require('proxy-addr');
2424

25+
/**
26+
* Determine whether a string is a valid port, i.e. only ASCII digits,
27+
* per RFC 3986 section 3.2.3 ("port = *DIGIT").
28+
*
29+
* @param {string} str
30+
* @return {boolean}
31+
* @private
32+
*/
33+
34+
function isValidPort (str) {
35+
for (var i = 0; i < str.length; i++) {
36+
var code = str.charCodeAt(i);
37+
if (code < 0x30 || code > 0x39) return false;
38+
}
39+
return true;
40+
}
41+
2542
/**
2643
* Request prototype.
2744
* @public
@@ -427,7 +444,25 @@ defineGetter(req, 'host', function host(){
427444
val = val.substring(0, val.indexOf(',')).trimEnd()
428445
}
429446

430-
return val || undefined;
447+
if (!val) return;
448+
449+
// A Host header must not contain userinfo (RFC 9110 section 7.2). Drop any
450+
// "user@" / "user:pass@" prefix so a crafted value like
451+
// "evil.com:x@good.com" cannot inject an attacker-controlled host via
452+
// req.hostname; the real host is the part after the last "@".
453+
var atIndex = val.lastIndexOf('@');
454+
if (atIndex !== -1) val = val.slice(atIndex + 1);
455+
456+
if (!val) return;
457+
458+
// The optional port must be numeric (RFC 3986 section 3.2.3). Reject
459+
// malformed authorities, e.g. an encoded "@" smuggled in as the port
460+
// ("evil.com:x%40good.com"), which would otherwise leak a fake hostname.
461+
var portOffset = val[0] === '[' ? val.indexOf(']') + 1 : 0;
462+
var portIndex = val.indexOf(':', portOffset);
463+
if (portIndex !== -1 && !isValidPort(val.slice(portIndex + 1))) return;
464+
465+
return val;
431466
});
432467

433468
/**

test/req.host.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,34 @@ describe('req', function(){
7070
.expect(200, '[::1]:3000', done);
7171
})
7272

73+
describe('with a Host header containing userinfo', function(){
74+
it('should return the real host, dropping the userinfo', function(done){
75+
var app = express();
76+
77+
app.use(function(req, res){
78+
res.end(req.host);
79+
});
80+
81+
request(app)
82+
.post('/')
83+
.set('Host', 'evil.com:fake@legitimate.com:3000')
84+
.expect(200, 'legitimate.com:3000', done);
85+
})
86+
87+
it('should return undefined for an encoded userinfo separator', function(done){
88+
var app = express();
89+
90+
app.use(function(req, res){
91+
res.end(String(req.host));
92+
});
93+
94+
request(app)
95+
.post('/')
96+
.set('Host', 'evil.com:x%40legitimate.com')
97+
.expect(200, 'undefined', done);
98+
})
99+
})
100+
73101
describe('when "trust proxy" is enabled', function(){
74102
it('should respect X-Forwarded-Host', function(done){
75103
var app = express();

test/req.hostname.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,125 @@ describe('req', function(){
7070
.expect('[::1]', done);
7171
})
7272

73+
it('should return undefined for a non-numeric port', function(done){
74+
var app = express();
75+
76+
app.use(function(req, res){
77+
res.end(String(req.hostname));
78+
});
79+
80+
request(app)
81+
.post('/')
82+
.set('Host', 'example.com:notaport')
83+
.expect('undefined', done);
84+
})
85+
86+
it('should return undefined for an IPv6 Host with a non-numeric port', function(done){
87+
var app = express();
88+
89+
app.use(function(req, res){
90+
res.end(String(req.hostname));
91+
});
92+
93+
request(app)
94+
.post('/')
95+
.set('Host', '[::1]:notaport')
96+
.expect('undefined', done);
97+
})
98+
99+
describe('with a Host header containing userinfo', function(){
100+
it('should return the real host, not the userinfo prefix', function(done){
101+
var app = express();
102+
103+
app.use(function(req, res){
104+
res.end(req.hostname);
105+
});
106+
107+
request(app)
108+
.post('/')
109+
.set('Host', 'evil.com:fake@legitimate.com:3000')
110+
.expect('legitimate.com', done);
111+
})
112+
113+
it('should ignore a userinfo section before the host', function(done){
114+
var app = express();
115+
116+
app.use(function(req, res){
117+
res.end(req.hostname);
118+
});
119+
120+
request(app)
121+
.post('/')
122+
.set('Host', 'user@example.com')
123+
.expect('example.com', done);
124+
})
125+
126+
it('should strip the port after removing userinfo', function(done){
127+
var app = express();
128+
129+
app.use(function(req, res){
130+
res.end(req.hostname);
131+
});
132+
133+
request(app)
134+
.post('/')
135+
.set('Host', 'user:pass@example.com:8080')
136+
.expect('example.com', done);
137+
})
138+
139+
it('should remove userinfo from an IPv6 Host', function(done){
140+
var app = express();
141+
142+
app.use(function(req, res){
143+
res.end(req.hostname);
144+
});
145+
146+
request(app)
147+
.post('/')
148+
.set('Host', 'user@[::1]:8080')
149+
.expect('[::1]', done);
150+
})
151+
152+
it('should use the host after the last "@" when several are present', function(done){
153+
var app = express();
154+
155+
app.use(function(req, res){
156+
res.end(req.hostname);
157+
});
158+
159+
request(app)
160+
.post('/')
161+
.set('Host', 'a@b@example.com')
162+
.expect('example.com', done);
163+
})
164+
165+
it('should return undefined for an encoded userinfo separator', function(done){
166+
var app = express();
167+
168+
app.use(function(req, res){
169+
res.end(String(req.hostname));
170+
});
171+
172+
request(app)
173+
.post('/')
174+
.set('Host', 'evil.com:x%40legitimate.com')
175+
.expect('undefined', done);
176+
})
177+
178+
it('should return undefined when there is no host after the userinfo', function(done){
179+
var app = express();
180+
181+
app.use(function(req, res){
182+
res.end(String(req.hostname));
183+
});
184+
185+
request(app)
186+
.post('/')
187+
.set('Host', 'user@')
188+
.expect('undefined', done);
189+
})
190+
})
191+
73192
describe('when "trust proxy" is enabled', function(){
74193
it('should respect X-Forwarded-Host', function(done){
75194
var app = express();
@@ -87,6 +206,22 @@ describe('req', function(){
87206
.expect('example.com', done);
88207
})
89208

209+
it('should drop userinfo from a trusted X-Forwarded-Host', function(done){
210+
var app = express();
211+
212+
app.enable('trust proxy');
213+
214+
app.use(function(req, res){
215+
res.end(req.hostname);
216+
});
217+
218+
request(app)
219+
.get('/')
220+
.set('Host', 'localhost')
221+
.set('X-Forwarded-Host', 'evil.com:fake@legitimate.com')
222+
.expect('legitimate.com', done);
223+
})
224+
90225
it('should ignore X-Forwarded-Host if socket addr not trusted', function(done){
91226
var app = express();
92227

0 commit comments

Comments
 (0)