Skip to content

Commit 6fffdf6

Browse files
authored
Merge pull request #6855 from erik-krogh/secCookie
JS: Move cookie queries out of experimental.
2 parents e94b2b6 + cfc5629 commit 6fffdf6

37 files changed

+1018
-931
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
lgtm,codescanning
2+
* The `js/clear-text-cookie` and `js/client-exposed-cookie` queries have been renamed and moved into non-experimental.

javascript/ql/lib/semmle/javascript/frameworks/CookieLibraries.qll

Lines changed: 280 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,97 @@
44

55
import javascript
66

7+
/**
8+
* Classes and predicates for reasoning about writes to cookies.
9+
*/
10+
module CookieWrites {
11+
/**
12+
* A write to a cookie.
13+
*/
14+
abstract class CookieWrite extends DataFlow::Node {
15+
/**
16+
* Holds if this cookie is secure, i.e. only transmitted over SSL.
17+
*/
18+
abstract predicate isSecure();
19+
20+
/**
21+
* Holds if this cookie is HttpOnly, i.e. not accessible by JavaScript.
22+
*/
23+
abstract predicate isHttpOnly();
24+
25+
/**
26+
* Holds if the cookie likely is an authentication cookie or otherwise sensitive.
27+
*/
28+
abstract predicate isSensitive();
29+
30+
/**
31+
* Holds if the cookie write happens on a server, i.e. the `httpOnly` flag is relevant.
32+
*/
33+
predicate isServerSide() {
34+
any() // holds by default. Client-side cookie writes should extend ClientSideCookieWrite.
35+
}
36+
}
37+
38+
/**
39+
* A client-side write to a cookie.
40+
*/
41+
abstract class ClientSideCookieWrite extends CookieWrite {
42+
final override predicate isHttpOnly() { none() }
43+
44+
final override predicate isServerSide() { none() }
45+
}
46+
47+
/**
48+
* The flag that indicates that a cookie is secure.
49+
*/
50+
string secure() { result = "secure" }
51+
52+
/**
53+
* The flag that indicates that a cookie is HttpOnly.
54+
*/
55+
string httpOnly() { result = "httpOnly" }
56+
}
57+
58+
/**
59+
* Holds if `node` looks like it can contain a sensitive cookie.
60+
*
61+
* Heuristics:
62+
* - `node` contains a string value that looks like a sensitive cookie name
63+
* - `node` is a sensitive expression
64+
*/
65+
private predicate canHaveSensitiveCookie(DataFlow::Node node) {
66+
exists(string s |
67+
node.mayHaveStringValue(s) or
68+
s = node.(StringOps::ConcatenationRoot).getConstantStringParts()
69+
|
70+
HeuristicNames::nameIndicatesSensitiveData([s, getCookieName(s)], _)
71+
)
72+
or
73+
node.asExpr() instanceof SensitiveExpr
74+
}
75+
76+
/**
77+
* Gets the cookie name of a `Set-Cookie` header value.
78+
* The header value always starts with `<cookie-name>=<cookie-value>` optionally followed by attributes:
79+
* `<cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly`
80+
*/
81+
bindingset[s]
82+
private string getCookieName(string s) { result = s.regexpCapture("([^=]*)=.*", 1).trim() }
83+
84+
/**
85+
* Holds if the `Set-Cookie` header value contains the specified attribute
86+
* 1. The attribute is case insensitive
87+
* 2. It always starts with a pair `<cookie-name>=<cookie-value>`.
88+
* If the attribute is present there must be `;` after the pair.
89+
* Other attributes like `Domain=`, `Path=`, etc. may come after the pair:
90+
* `<cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly`
91+
* See `https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie`
92+
*/
93+
bindingset[s, attribute]
94+
private predicate hasCookieAttribute(string s, string attribute) {
95+
s.regexpMatch("(?i).*;\\s*" + attribute + "\\b\\s*;?.*$")
96+
}
97+
798
/**
899
* A model of the `js-cookie` library (https://github.com/js-cookie/js-cookie).
9100
*/
@@ -25,12 +116,22 @@ private module JsCookie {
25116
}
26117
}
27118

28-
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode {
119+
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode,
120+
CookieWrites::ClientSideCookieWrite {
29121
WriteAccess() { this = libMemberCall("set") }
30122

31123
string getKey() { getArgument(0).mayHaveStringValue(result) }
32124

33125
override DataFlow::Node getValue() { result = getArgument(1) }
126+
127+
override predicate isSecure() {
128+
// A cookie is secure if there are cookie options with the `secure` flag set to `true`.
129+
exists(DataFlow::Node value | value = this.getOptionArgument(2, CookieWrites::secure()) |
130+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
131+
)
132+
}
133+
134+
override predicate isSensitive() { canHaveSensitiveCookie(this.getArgument(0)) }
34135
}
35136
}
36137

@@ -53,12 +154,25 @@ private module BrowserCookies {
53154
}
54155
}
55156

56-
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode {
157+
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode,
158+
CookieWrites::ClientSideCookieWrite {
57159
WriteAccess() { this = libMemberCall("set") }
58160

59161
string getKey() { getArgument(0).mayHaveStringValue(result) }
60162

61163
override DataFlow::Node getValue() { result = getArgument(1) }
164+
165+
override predicate isSecure() {
166+
// A cookie is secure if there are cookie options with the `secure` flag set to `true`.
167+
exists(DataFlow::Node value | value = this.getOptionArgument(2, CookieWrites::secure()) |
168+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
169+
)
170+
or
171+
// or, an explicit default has been set
172+
exists(DataFlow::moduleMember("browser-cookies", "defaults").getAPropertyWrite("secure"))
173+
}
174+
175+
override predicate isSensitive() { canHaveSensitiveCookie(this.getArgument(0)) }
62176
}
63177
}
64178

@@ -81,11 +195,174 @@ private module LibCookie {
81195
override PersistentWriteAccess getAWrite() { key = result.(WriteAccess).getKey() }
82196
}
83197

84-
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode {
198+
class WriteAccess extends PersistentWriteAccess, DataFlow::CallNode,
199+
CookieWrites::ClientSideCookieWrite {
85200
WriteAccess() { this = libMemberCall("serialize") }
86201

87202
string getKey() { getArgument(0).mayHaveStringValue(result) }
88203

89204
override DataFlow::Node getValue() { result = getArgument(1) }
205+
206+
override predicate isSecure() {
207+
// A cookie is secure if there are cookie options with the `secure` flag set to `true`.
208+
exists(DataFlow::Node value | value = this.getOptionArgument(2, CookieWrites::secure()) |
209+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
210+
)
211+
}
212+
213+
override predicate isSensitive() { canHaveSensitiveCookie(this.getArgument(0)) }
214+
}
215+
}
216+
217+
/**
218+
* A model of cookies in an express application.
219+
*/
220+
private module ExpressCookies {
221+
/**
222+
* A cookie set using `response.cookie` from `express` module (https://expressjs.com/en/api.html#res.cookie).
223+
*/
224+
private class InsecureExpressCookieResponse extends CookieWrites::CookieWrite,
225+
DataFlow::MethodCallNode {
226+
InsecureExpressCookieResponse() { this.asExpr() instanceof Express::SetCookie }
227+
228+
override predicate isSecure() {
229+
// A cookie is secure if there are cookie options with the `secure` flag set to `true`.
230+
// The default is `false`.
231+
exists(DataFlow::Node value | value = this.getOptionArgument(2, CookieWrites::secure()) |
232+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
233+
)
234+
}
235+
236+
override predicate isSensitive() { canHaveSensitiveCookie(this.getArgument(0)) }
237+
238+
override predicate isHttpOnly() {
239+
// A cookie is httpOnly if there are cookie options with the `httpOnly` flag set to `true`.
240+
// The default is `false`.
241+
exists(DataFlow::Node value | value = this.getOptionArgument(2, CookieWrites::httpOnly()) |
242+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
243+
)
244+
}
245+
}
246+
247+
/**
248+
* A cookie set using the `express` module `cookie-session` (https://github.com/expressjs/cookie-session).
249+
*/
250+
class InsecureCookieSession extends ExpressLibraries::CookieSession::MiddlewareInstance,
251+
CookieWrites::CookieWrite {
252+
private DataFlow::Node getCookieFlagValue(string flag) {
253+
result = this.getOptionArgument(0, flag)
254+
}
255+
256+
override predicate isSecure() {
257+
// The flag `secure` is set to `false` by default for HTTP, `true` by default for HTTPS (https://github.com/expressjs/cookie-session#cookie-options).
258+
// A cookie is secure if the `secure` flag is not explicitly set to `false`.
259+
not getCookieFlagValue(CookieWrites::secure()).mayHaveBooleanValue(false)
260+
}
261+
262+
override predicate isSensitive() {
263+
any() // It is a session cookie, likely auth sensitive
264+
}
265+
266+
override predicate isHttpOnly() {
267+
// The flag `httpOnly` is set to `true` by default (https://github.com/expressjs/cookie-session#cookie-options).
268+
// A cookie is httpOnly if the `httpOnly` flag is not explicitly set to `false`.
269+
not getCookieFlagValue(CookieWrites::httpOnly()).mayHaveBooleanValue(false)
270+
}
271+
}
272+
273+
/**
274+
* A cookie set using the `express` module `express-session` (https://github.com/expressjs/session).
275+
*/
276+
class InsecureExpressSessionCookie extends ExpressLibraries::ExpressSession::MiddlewareInstance,
277+
CookieWrites::CookieWrite {
278+
private DataFlow::Node getCookieFlagValue(string flag) {
279+
result = this.getOption("cookie").getALocalSource().getAPropertyWrite(flag).getRhs()
280+
}
281+
282+
override predicate isSecure() {
283+
// The flag `secure` is not set by default (https://github.com/expressjs/session#Cookiesecure).
284+
// The default value for cookie options is { path: '/', httpOnly: true, secure: false, maxAge: null }.
285+
exists(DataFlow::Node value | value = getCookieFlagValue(CookieWrites::secure()) |
286+
not value.mayHaveBooleanValue(false) // anything but `false` is accepted as being maybe true
287+
)
288+
}
289+
290+
override predicate isSensitive() {
291+
any() // It is a session cookie, likely auth sensitive
292+
}
293+
294+
override predicate isHttpOnly() {
295+
// The flag `httpOnly` is set by default (https://github.com/expressjs/session#Cookiesecure).
296+
// The default value for cookie options is { path: '/', httpOnly: true, secure: false, maxAge: null }.
297+
// A cookie is httpOnly if the `httpOnly` flag is not explicitly set to `false`.
298+
not getCookieFlagValue(CookieWrites::httpOnly()).mayHaveBooleanValue(false)
299+
}
300+
}
301+
}
302+
303+
/**
304+
* A cookie set using `Set-Cookie` header of an `HTTP` response, where a raw header is used.
305+
* (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie).
306+
* This class does not model the Express implementation of `HTTP::CookieDefintion`
307+
* as the express implementation does not use raw headers.
308+
*
309+
* In case an array is passed `setHeader("Set-Cookie", [...]` it sets multiple cookies.
310+
* We model a `CookieWrite` for each array element.
311+
*/
312+
private class HTTPCookieWrite extends CookieWrites::CookieWrite {
313+
string header;
314+
315+
HTTPCookieWrite() {
316+
exists(HTTP::CookieDefinition setCookie |
317+
this.asExpr() = setCookie.getHeaderArgument() and
318+
not this instanceof DataFlow::ArrayCreationNode
319+
or
320+
this = setCookie.getHeaderArgument().flow().(DataFlow::ArrayCreationNode).getAnElement()
321+
) and
322+
header =
323+
[
324+
any(string s | this.mayHaveStringValue(s)),
325+
this.(StringOps::ConcatenationRoot).getConstantStringParts()
326+
]
327+
}
328+
329+
override predicate isSecure() {
330+
// A cookie is secure if the `secure` flag is specified in the cookie definition.
331+
// The default is `false`.
332+
hasCookieAttribute(header, CookieWrites::secure())
90333
}
334+
335+
override predicate isHttpOnly() {
336+
// A cookie is httpOnly if the `httpOnly` flag is specified in the cookie definition.
337+
// The default is `false`.
338+
hasCookieAttribute(header, CookieWrites::httpOnly())
339+
}
340+
341+
override predicate isSensitive() { canHaveSensitiveCookie(this) }
342+
}
343+
344+
/**
345+
* A write to `document.cookie`.
346+
*/
347+
private class DocumentCookieWrite extends CookieWrites::ClientSideCookieWrite {
348+
string cookie;
349+
DataFlow::PropWrite write;
350+
351+
DocumentCookieWrite() {
352+
this = write and
353+
write = DOM::documentRef().getAPropertyWrite("cookie") and
354+
cookie =
355+
[
356+
any(string s | write.getRhs().mayHaveStringValue(s)),
357+
write.getRhs().(StringOps::ConcatenationRoot).getConstantStringParts()
358+
]
359+
}
360+
361+
override predicate isSecure() {
362+
// A cookie is secure if the `secure` flag is specified in the cookie definition.
363+
// The default is `false`.
364+
hasCookieAttribute(cookie, CookieWrites::secure())
365+
}
366+
367+
override predicate isSensitive() { canHaveSensitiveCookie(write.getRhs()) }
91368
}

javascript/ql/lib/semmle/javascript/security/SensitiveActions.qll

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ import semmle.javascript.security.internal.SensitiveDataHeuristics
1414
private import HeuristicNames
1515

1616
/** An expression that might contain sensitive data. */
17+
cached
1718
abstract class SensitiveExpr extends Expr {
1819
/** Gets a human-readable description of this expression for use in alert messages. */
20+
cached
1921
abstract string describe();
2022

2123
/** Gets a classification of the kind of sensitive data this expression might contain. */
24+
cached
2225
abstract SensitiveDataClassification getClassification();
2326
}
2427

javascript/ql/lib/semmle/javascript/security/internal/SensitiveDataHeuristics.qll

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ module HeuristicNames {
5858
*/
5959
string maybeAccountInfo() {
6060
result = "(?is).*acc(ou)?nt.*" or
61-
result = "(?is).*(puid|username|userid).*" or
61+
result = "(?is).*(puid|username|userid|session(id|key)).*" or
6262
result = "(?s).*([uU]|^|_|[a-z](?=U))([uU][iI][dD]).*"
6363
}
6464

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
Authentication cookies stored by a server can be accessed by a client if the <code>httpOnly</code> flag is not set.
9+
</p>
10+
<p>
11+
An attacker that manages a cross-site scripting (XSS) attack can read the cookie and hijack the session.
12+
</p>
13+
</overview>
14+
15+
<recommendation>
16+
<p>
17+
Set the <code>httpOnly</code> flag on all cookies that are not needed by the client.
18+
</p>
19+
</recommendation>
20+
21+
<example>
22+
<p>
23+
The following example stores an authentication token in a cookie that can
24+
be viewed by the client.
25+
</p>
26+
<sample src="examples/ClientExposedCookieGood.js"/>
27+
<p>
28+
To force the cookie to be transmitted using SSL, set the <code>secure</code>
29+
attribute on the cookie.
30+
</p>
31+
<sample src="examples/ClientExposedCookieBad.js"/>
32+
</example>
33+
34+
<references>
35+
<li>ExpressJS: <a href="https://expressjs.com/en/advanced/best-practice-security.html#use-cookies-securely">Use cookies securely</a>.</li>
36+
<li>OWASP: <a href="https://cheatsheetseries.owasp.org/cheatsheets/Nodejs_Security_Cheat_Sheet.html#set-cookie-flags-appropriately">Set cookie flags appropriately</a>.</li>
37+
<li>Mozilla: <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">Set-Cookie</a>.</li>
38+
</references>
39+
40+
</qhelp>

0 commit comments

Comments
 (0)