Skip to content

Commit 92afcd3

Browse files
author
Max Schaefer
authored
Merge pull request #241 from asger-semmle/host-header-forgery
JS: Add HostHeaderPoisoningInEmailGeneration query
2 parents 13ef492 + d005d71 commit 92afcd3

27 files changed

+470
-67
lines changed

change-notes/1.19/analysis-javascript.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
| Enabling Node.js integration for Electron web content renderers (`js/enabling-electron-renderer-node-integration`) | security, frameworks/electron, external/cwe/cwe-094 | Highlights Electron web content renderer preferences with Node.js integration enabled, indicating a violation of [CWE-94](https://cwe.mitre.org/data/definitions/94.html). Results are not shown on LGTM by default. |
1818
| Stored cross-site scripting (`js/stored-xss`) | security, external/cwe/cwe-079, external/cwe/cwe-116 | Highlights uncontrolled stored values flowing into HTML content, indicating a violation of [CWE-079](https://cwe.mitre.org/data/definitions/79.html). Results shown on LGTM by default. |
1919
| Replacement of a substring with itself (`js/identity-replacement`) | correctness, security, external/cwe/cwe-116 | Highlights string replacements that replace a string with itself, which usually indicates a mistake. Results shown on LGTM by default. |
20+
| Host header poisoning in email generation | security, external/cwe/cwe-640 | Highlights code that generates emails with links that can be hijacked by HTTP host header poisoning, indicating a violation of [CWE-640](https://cwe.mitre.org/data/definitions/640.html). Results shown on LGTM by default. |
2021

2122
## Changes to existing queries
2223

javascript/config/suites/javascript/security

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
+ semmlecode-javascript-queries/Security/CWE-601/ClientSideUrlRedirect.ql: /Security/CWE/CWE-601
2323
+ semmlecode-javascript-queries/Security/CWE-601/ServerSideUrlRedirect.ql: /Security/CWE/CWE-601
2424
+ semmlecode-javascript-queries/Security/CWE-611/Xxe.ql: /Security/CWE/CWE-611
25+
+ semmlecode-javascript-queries/Security/CWE-640/HostHeaderPoisoningInEmailGeneration.ql: /Security/CWE/CWE-640
2526
+ semmlecode-javascript-queries/Security/CWE-643/XpathInjection.ql: /Security/CWE/CWE-643
2627
+ semmlecode-javascript-queries/Security/CWE-730/RegExpInjection.ql: /Security/CWE/CWE-730
2728
+ semmlecode-javascript-queries/Security/CWE-770/MissingRateLimiting.ql: /Security/CWE/CWE-770
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<!DOCTYPE qhelp PUBLIC "-//Semmle//qhelp//EN" "qhelp.dtd">
2+
<qhelp>
3+
4+
<overview>
5+
<p>
6+
Using the HTTP Host header to construct a link in an email can facilitate phishing attacks and leak password reset tokens.
7+
A malicious user can send an HTTP request to the targeted web site, but with a Host header that refers to his own web site.
8+
This means the emails will be sent out to potential victims, originating from a server they trust, but with
9+
links leading to a malicious web site.
10+
</p>
11+
<p>
12+
If the email contains a password reset link, and should the victim click the link, the secret reset token will be leaked to the attacker.
13+
Using the leaked token, the attacker can then construct the real reset link and use it to change the victim's password.
14+
</p>
15+
</overview>
16+
17+
<recommendation>
18+
<p>
19+
Obtain the server's host name from a configuration file and avoid relying on the Host header.
20+
</p>
21+
</recommendation>
22+
23+
<example>
24+
<p>
25+
The following example uses the <code>req.host</code> to generate a password reset link.
26+
This value is derived from the Host header, and can thus be set to anything by an attacker:
27+
</p>
28+
<sample src="examples/HostHeaderPoisoningInEmailGeneration.js"/>
29+
30+
<p>
31+
To ensure the link refers to the correct web site, get the host name from a configuration file:
32+
</p>
33+
<sample src="examples/HostHeaderPoisoningInEmailGenerationGood.js"/>
34+
</example>
35+
36+
<references>
37+
<li>
38+
Mitre:
39+
<a href="https://cwe.mitre.org/data/definitions/640.html">CWE-640: Weak Password Recovery Mechanism for Forgotten Password</a>.
40+
</li>
41+
<li>
42+
Ian Muscat:
43+
<a href="https://www.acunetix.com/blog/articles/automated-detection-of-host-header-attacks/">What is a Host Header Attack?</a>.
44+
</li>
45+
</references>
46+
</qhelp>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @name Host header poisoning in email generation
3+
* @description Using the HTTP Host header to construct a link in an email can facilitate phishing attacks and leak password reset tokens.
4+
* @kind problem
5+
* @problem.severity error
6+
* @precision high
7+
* @id js/host-header-forgery-in-email-generation
8+
* @tags security
9+
* external/cwe/cwe-640
10+
*/
11+
import javascript
12+
13+
class TaintedHostHeader extends TaintTracking::Configuration {
14+
TaintedHostHeader() { this = "TaintedHostHeader" }
15+
16+
override predicate isSource(DataFlow::Node node) {
17+
exists (HTTP::RequestHeaderAccess input | node = input |
18+
input.getKind() = "header" and
19+
input.getAHeaderName() = "host")
20+
}
21+
22+
override predicate isSink(DataFlow::Node node) {
23+
exists (EmailSender email | node = email.getABody())
24+
}
25+
}
26+
27+
from TaintedHostHeader taint, DataFlow::Node src, DataFlow::Node sink
28+
where taint.hasFlow(src, sink)
29+
select sink, "Links in this email can be hijacked by poisoning the HTTP host header $@.", src, "here"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
let nodemailer = require('nodemailer');
2+
let express = require('express');
3+
let backend = require('./backend');
4+
5+
let app = express();
6+
7+
let config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
8+
9+
app.post('/resetpass', (req, res) => {
10+
let email = req.query.email;
11+
let transport = nodemailer.createTransport(config.smtp);
12+
let token = backend.getUserSecretResetToken(email);
13+
transport.sendMail({
14+
from: 'webmaster@example.com',
15+
to: email,
16+
subject: 'Forgot password',
17+
text: `Click to reset password: https://${req.host}/resettoken/${token}`,
18+
});
19+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
let nodemailer = require('nodemailer');
2+
let express = require('express');
3+
let backend = require('./backend');
4+
5+
let app = express();
6+
7+
let config = JSON.parse(fs.readFileSync('config.json', 'utf8'));
8+
9+
app.post('/resetpass', (req, res) => {
10+
let email = req.query.email;
11+
let transport = nodemailer.createTransport(config.smtp);
12+
let token = backend.getUserSecretResetToken(email);
13+
transport.sendMail({
14+
from: 'webmaster@example.com',
15+
to: email,
16+
subject: 'Forgot password',
17+
text: `Click to reset password: https://${config.hostname}/resettoken/${token}`,
18+
});
19+
});

javascript/ql/src/javascript.qll

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import semmle.javascript.Constants
1313
import semmle.javascript.DataFlow
1414
import semmle.javascript.DefUse
1515
import semmle.javascript.DOM
16+
import semmle.javascript.EmailClients
1617
import semmle.javascript.Errors
1718
import semmle.javascript.ES2015Modules
1819
import semmle.javascript.Expr
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import javascript
2+
3+
/**
4+
* An operation that sends an email.
5+
*/
6+
abstract class EmailSender extends DataFlow::DefaultSourceNode {
7+
/**
8+
* Gets a data flow node holding the plaintext version of the email body.
9+
*/
10+
abstract DataFlow::Node getPlainTextBody();
11+
12+
/**
13+
* Gets a data flow node holding the HTML body of the email.
14+
*/
15+
abstract DataFlow::Node getHtmlBody();
16+
17+
/**
18+
* Gets a data flow node holding the address of the email recipient(s).
19+
*/
20+
abstract DataFlow::Node getTo();
21+
22+
/**
23+
* Gets a data flow node holding the address of the email sender.
24+
*/
25+
abstract DataFlow::Node getFrom();
26+
27+
/**
28+
* Gets a data flow node holding the email subject.
29+
*/
30+
abstract DataFlow::Node getSubject();
31+
32+
/**
33+
* Gets a data flow node that refers to the HTML body or plaintext body of the email.
34+
*/
35+
DataFlow::Node getABody() {
36+
result = getPlainTextBody() or
37+
result = getHtmlBody()
38+
}
39+
}
40+
41+
/**
42+
* An email-sending call based on the `nodemailer` package.
43+
*/
44+
private class NodemailerEmailSender extends EmailSender, DataFlow::MethodCallNode {
45+
NodemailerEmailSender() {
46+
this = DataFlow::moduleMember("nodemailer", "createTransport").getACall().getAMethodCall("sendMail")
47+
}
48+
49+
override DataFlow::Node getPlainTextBody() {
50+
result = getOptionArgument(0, "text")
51+
}
52+
53+
override DataFlow::Node getHtmlBody() {
54+
result = getOptionArgument(0, "html")
55+
}
56+
57+
override DataFlow::Node getTo() {
58+
result = getOptionArgument(0, "to")
59+
}
60+
61+
override DataFlow::Node getFrom() {
62+
result = getOptionArgument(0, "from")
63+
}
64+
65+
override DataFlow::Node getSubject() {
66+
result = getOptionArgument(0, "subject")
67+
}
68+
}

javascript/ql/src/semmle/javascript/frameworks/Express.qll

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -471,37 +471,69 @@ module Express {
471471
propName = "originalUrl"
472472
)
473473
or
474+
// `req.cookies`
475+
kind = "cookie" and
476+
this.(DataFlow::PropRef).accesses(request, "cookies")
477+
)
478+
or
479+
exists (RequestHeaderAccess access | this = access |
480+
rh = access.getRouteHandler() and
481+
kind = "header")
482+
}
483+
484+
override RouteHandler getRouteHandler() {
485+
result = rh
486+
}
487+
488+
override string getKind() {
489+
result = kind
490+
}
491+
}
492+
493+
/**
494+
* An access to a header on an Express request.
495+
*/
496+
private class RequestHeaderAccess extends HTTP::RequestHeaderAccess {
497+
RouteHandler rh;
498+
499+
RequestHeaderAccess() {
500+
exists (DataFlow::Node request | request = DataFlow::valueNode(rh.getARequestExpr()) |
474501
exists (string methodName |
475502
// `req.get(...)` or `req.header(...)`
476-
kind = "header" and
477503
this.(DataFlow::MethodCallNode).calls(request, methodName) |
478504
methodName = "get" or
479505
methodName = "header"
480506
)
481507
or
482508
exists (DataFlow::PropRead headers |
483509
// `req.headers.name`
484-
kind = "header" and
485510
headers.accesses(request, "headers") and
486511
this = headers.getAPropertyRead())
487512
or
488513
exists (string propName | propName = "host" or propName = "hostname" |
489514
// `req.host` and `req.hostname` are derived from headers
490-
kind = "header" and
491515
this.(DataFlow::PropRead).accesses(request, propName))
492-
or
493-
// `req.cookies`
494-
kind = "cookie" and
495-
this.(DataFlow::PropRef).accesses(request, "cookies")
496516
)
497517
}
498518

519+
override string getAHeaderName() {
520+
exists (string name |
521+
name = this.(DataFlow::PropRead).getPropertyName()
522+
or
523+
this.(DataFlow::CallNode).getArgument(0).mayHaveStringValue(name)
524+
|
525+
if name = "hostname" then
526+
result = "host"
527+
else
528+
result = name.toLowerCase())
529+
}
530+
499531
override RouteHandler getRouteHandler() {
500532
result = rh
501533
}
502534

503535
override string getKind() {
504-
result = kind
536+
result = "header"
505537
}
506538
}
507539

@@ -600,9 +632,9 @@ module Express {
600632
astNode.getMethodName() = any(string n | n = "set" or n = "header") and
601633
astNode.getNumArgument() = 1
602634
}
603-
635+
604636
/**
605-
* Gets a reference to the multiple headers object that is to be set.
637+
* Gets a reference to the multiple headers object that is to be set.
606638
*/
607639
private DataFlow::SourceNode getAHeaderSource() {
608640
result.flowsToExpr(astNode.getArgument(0))
@@ -618,12 +650,12 @@ module Express {
618650
override RouteHandler getRouteHandler() {
619651
result = rh
620652
}
621-
653+
622654
override Expr getNameExpr() {
623-
exists (DataFlow::PropWrite write |
655+
exists (DataFlow::PropWrite write |
624656
getAHeaderSource().flowsTo(write.getBase()) and
625657
result = write.getPropertyNameExpr()
626-
)
658+
)
627659
}
628660
}
629661

javascript/ql/src/semmle/javascript/frameworks/HTTP.qll

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ module HTTP {
7272
* Holds if the header with (lower-case) name `headerName` is set to the value of `headerValue`.
7373
*/
7474
abstract predicate definesExplicitly(string headerName, Expr headerValue);
75-
75+
7676
/**
77-
* Returns the expression used to compute the header name.
77+
* Returns the expression used to compute the header name.
7878
*/
7979
abstract Expr getNameExpr();
8080
}
@@ -354,9 +354,9 @@ module HTTP {
354354
headerName = getNameExpr().getStringValue().toLowerCase() and
355355
headerValue = astNode.getArgument(1)
356356
}
357-
357+
358358
override Expr getNameExpr() {
359-
result = astNode.getArgument(0)
359+
result = astNode.getArgument(0)
360360
}
361361

362362
}
@@ -400,7 +400,20 @@ module HTTP {
400400
*/
401401
abstract string getKind();
402402
}
403-
403+
404+
/**
405+
* An access to a header on an incoming HTTP request.
406+
*/
407+
abstract class RequestHeaderAccess extends RequestInputAccess {
408+
/**
409+
* Gets the lower-case name of an HTTP header from which this input is derived,
410+
* if this can be determined.
411+
*
412+
* When the name of the header is unknown, this has no result.
413+
*/
414+
abstract string getAHeaderName();
415+
}
416+
404417
/**
405418
* A node that looks like a route setup on a server.
406419
*

0 commit comments

Comments
 (0)