mirror of
https://github.com/github/codeql.git
synced 2026-01-06 11:10:23 +01:00
Merge pull request #4951 from esbena/js/reintroduce-server-crash
Approved by erik-krogh
This commit is contained in:
86
javascript/ql/src/Security/CWE-730/ServerCrash.qhelp
Normal file
86
javascript/ql/src/Security/CWE-730/ServerCrash.qhelp
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE qhelp PUBLIC
|
||||
"-//Semmle//qhelp//EN"
|
||||
"qhelp.dtd">
|
||||
<qhelp>
|
||||
|
||||
<overview>
|
||||
|
||||
<p>
|
||||
|
||||
Servers handle requests from clients until terminated
|
||||
deliberately by a server administrator. A client request that results
|
||||
in an uncaught server-side exception causes the current server
|
||||
response generation to fail, and should not have an effect on
|
||||
subsequent client requests.
|
||||
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
||||
Under some circumstances, uncaught exceptions can however
|
||||
cause the entire server to terminate abruptly. Such a behavior is
|
||||
highly undesirable, especially if it gives malicious users the ability
|
||||
to turn off the server at will, which is an efficient
|
||||
denial-of-service attack.
|
||||
|
||||
</p>
|
||||
|
||||
</overview>
|
||||
|
||||
<recommendation>
|
||||
|
||||
<p>
|
||||
|
||||
Ensure that the processing of client requests can not cause
|
||||
uncaught exceptions to terminate the entire server abruptly.
|
||||
|
||||
</p>
|
||||
|
||||
</recommendation>
|
||||
|
||||
<example>
|
||||
|
||||
<p>
|
||||
|
||||
The following server implementation checks if a client-provided
|
||||
file path is valid and throws an exception if the check fails. It can
|
||||
be seen that the exception is uncaught, and it is therefore reasonable to
|
||||
expect the server to respond with an error response to client requests
|
||||
that cause the check to fail.
|
||||
|
||||
But since the exception is uncaught in the context of an
|
||||
asynchronous callback invocation (<code>fs.access(...)</code>), the
|
||||
entire server will terminate instead.
|
||||
|
||||
</p>
|
||||
|
||||
<sample src="examples/server-crash.BAD.js"/>
|
||||
|
||||
<p>
|
||||
To remedy this, the server can catch the exception explicitly with
|
||||
a <code>try/catch</code> block, and generate an appropriate error
|
||||
response instead:
|
||||
|
||||
</p>
|
||||
|
||||
<sample src="examples/server-crash.GOOD-A.js"/>
|
||||
|
||||
<p>
|
||||
|
||||
An alternative is to use an <code>async</code> and
|
||||
<code>await</code> for the asynchronous behavior, since the server
|
||||
will then print warning messages about uncaught exceptions instead of
|
||||
terminating, unless the server was started with the commandline option
|
||||
<code>--unhandled-rejections=strict</code>:
|
||||
|
||||
</p>
|
||||
|
||||
<sample src="examples/server-crash.GOOD-B.js"/>
|
||||
|
||||
</example>
|
||||
|
||||
<references>
|
||||
|
||||
</references>
|
||||
|
||||
</qhelp>
|
||||
189
javascript/ql/src/Security/CWE-730/ServerCrash.ql
Normal file
189
javascript/ql/src/Security/CWE-730/ServerCrash.ql
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @name Server crash
|
||||
* @description A server that can be forced to crash may be vulnerable to denial-of-service
|
||||
* attacks.
|
||||
* @kind path-problem
|
||||
* @problem.severity warning
|
||||
* @precision high
|
||||
* @id js/server-crash
|
||||
* @tags security
|
||||
* external/cwe/cwe-730
|
||||
*/
|
||||
|
||||
import javascript
|
||||
|
||||
/**
|
||||
* Gets a function that indirectly invokes an asynchronous callback through `async`, where the callback throws an uncaught exception at `thrower`.
|
||||
*/
|
||||
Function invokesCallbackThatThrowsUncaughtException(
|
||||
AsyncSentinelCall async, LikelyExceptionThrower thrower
|
||||
) {
|
||||
async.getAsyncCallee() = throwsUncaughtExceptionInAsyncContext(thrower) and
|
||||
result = async.getEnclosingFunction()
|
||||
or
|
||||
exists(DataFlow::InvokeNode invk, Function fun |
|
||||
fun = invokesCallbackThatThrowsUncaughtException(async, thrower) and
|
||||
// purposely not checking for `getEnclosingTryCatchStmt`. An async callback called from inside a try-catch can still crash the server.
|
||||
result = invk.getEnclosingFunction()
|
||||
|
|
||||
invk.getACallee() = fun
|
||||
or
|
||||
// traverse a slightly extended call graph to get additional TPs
|
||||
invk.(AsyncSentinelCall).getAsyncCallee() = fun
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a callee of an invocation `invk` that is not guarded by a try statement.
|
||||
*/
|
||||
Function getUncaughtExceptionRethrowerCallee(DataFlow::InvokeNode invk) {
|
||||
not exists(invk.asExpr().getEnclosingStmt().getEnclosingTryCatchStmt()) and
|
||||
result = invk.getACallee()
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds if `thrower` is not guarded by a try statement.
|
||||
*/
|
||||
predicate isUncaughtExceptionThrower(LikelyExceptionThrower thrower) {
|
||||
not exists([thrower.(Expr).getEnclosingStmt(), thrower.(Stmt)].getEnclosingTryCatchStmt())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a function that may throw an uncaught exception originating at `thrower`, which then may escape in an asynchronous calling context.
|
||||
*/
|
||||
Function throwsUncaughtExceptionInAsyncContext(LikelyExceptionThrower thrower) {
|
||||
(
|
||||
isUncaughtExceptionThrower(thrower) and
|
||||
result = thrower.getContainer()
|
||||
or
|
||||
exists(DataFlow::InvokeNode invk |
|
||||
getUncaughtExceptionRethrowerCallee(invk) = throwsUncaughtExceptionInAsyncContext(thrower) and
|
||||
result = invk.getEnclosingFunction()
|
||||
)
|
||||
) and
|
||||
// Anti-case:
|
||||
// An exception from an `async` function results in a rejected promise.
|
||||
// Unhandled promises requires `node --unhandled-rejections=strict ...` to terminate the process
|
||||
// without that flag, the DEP0018 deprecation warning is printed instead (node.js version 14 and below)
|
||||
not result.isAsync() and
|
||||
// pruning optimization since this predicate always is related to `invokesCallbackThatThrowsUncaughtException`
|
||||
result = reachableFromAsyncCallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds if `result` is reachable from a callback that is invoked asynchronously.
|
||||
*/
|
||||
Function reachableFromAsyncCallback() {
|
||||
result instanceof AsyncCallback
|
||||
or
|
||||
exists(DataFlow::InvokeNode invk |
|
||||
invk.getEnclosingFunction() = reachableFromAsyncCallback() and
|
||||
result = invk.getACallee()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The main predicate of this query: used for both result display and path computation.
|
||||
*/
|
||||
predicate main(
|
||||
HTTP::RouteHandler rh, AsyncSentinelCall async, AsyncCallback cb, LikelyExceptionThrower thrower
|
||||
) {
|
||||
async.getAsyncCallee() = cb and
|
||||
rh.getAstNode() = invokesCallbackThatThrowsUncaughtException(async, thrower)
|
||||
}
|
||||
|
||||
/**
|
||||
* A call that may cause a function to be invoked in an asynchronous context outside of the visible source code.
|
||||
*/
|
||||
class AsyncSentinelCall extends DataFlow::CallNode {
|
||||
Function asyncCallee;
|
||||
|
||||
AsyncSentinelCall() {
|
||||
exists(DataFlow::FunctionNode node | node.getAstNode() = asyncCallee |
|
||||
// manual models
|
||||
exists(string memberName |
|
||||
not "Sync" = memberName.suffix(memberName.length() - 4) and
|
||||
this = NodeJSLib::FS::moduleMember(memberName).getACall() and
|
||||
node = this.getCallback([1 .. 2])
|
||||
)
|
||||
// (add additional cases here to improve the query)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the callee that is invoked in an asynchronous context.
|
||||
*/
|
||||
Function getAsyncCallee() { result = asyncCallee }
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback provided to an asynchronous call (heuristic).
|
||||
*/
|
||||
class AsyncCallback extends Function {
|
||||
AsyncCallback() { any(AsyncSentinelCall c).getAsyncCallee() = this }
|
||||
}
|
||||
|
||||
/**
|
||||
* A node that is likely to throw an exception.
|
||||
*
|
||||
* This is the primary extension point for this query.
|
||||
*/
|
||||
abstract class LikelyExceptionThrower extends ASTNode { }
|
||||
|
||||
/**
|
||||
* A `throw` statement.
|
||||
*/
|
||||
class TrivialThrowStatement extends LikelyExceptionThrower, ThrowStmt { }
|
||||
|
||||
/**
|
||||
* Empty class for avoiding emptiness checks from the compiler when there are no Expr-typed instances of the LikelyExceptionThrower type.
|
||||
*/
|
||||
class CompilerConfusingExceptionThrower extends LikelyExceptionThrower {
|
||||
CompilerConfusingExceptionThrower() { none() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edges that builds an explanatory graph that follows the mental model of how the the exception flows.
|
||||
*
|
||||
* - step 1. exception is thrown
|
||||
* - step 2. exception escapes the enclosing function
|
||||
* - step 3. exception follows the call graph backwards until an async callee is encountered
|
||||
* - step 4. (at this point, the program crashes)
|
||||
*/
|
||||
query predicate edges(ASTNode pred, ASTNode succ) {
|
||||
exists(LikelyExceptionThrower thrower | main(_, _, _, thrower) |
|
||||
pred = thrower and
|
||||
succ = thrower.getContainer()
|
||||
or
|
||||
exists(DataFlow::InvokeNode invk, Function fun |
|
||||
fun = throwsUncaughtExceptionInAsyncContext(thrower)
|
||||
|
|
||||
succ = invk.getAstNode() and
|
||||
pred = invk.getACallee() and
|
||||
pred = fun
|
||||
or
|
||||
succ = fun and
|
||||
succ = invk.getContainer() and
|
||||
pred = invk.getAstNode()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A node in the `edge/2` relation above.
|
||||
*/
|
||||
query predicate nodes(ASTNode node) {
|
||||
edges(node, _) or
|
||||
edges(_, node)
|
||||
}
|
||||
|
||||
from
|
||||
HTTP::RouteHandler rh, AsyncSentinelCall async, DataFlow::Node callbackArg, AsyncCallback cb,
|
||||
ExprOrStmt crasher
|
||||
where
|
||||
main(rh, async, cb, crasher) and
|
||||
callbackArg.getALocalSource().getAstNode() = cb and
|
||||
async.getAnArgument() = callbackArg
|
||||
select crasher, crasher, cb,
|
||||
"The server of $@ will terminate when an uncaught exception from here escapes this $@", rh,
|
||||
"this route handler", callbackArg, "asynchronous callback"
|
||||
@@ -0,0 +1,22 @@
|
||||
const express = require("express"),
|
||||
fs = require("fs");
|
||||
|
||||
function save(rootDir, path, content) {
|
||||
if (!isValidPath(rootDir, req.query.filePath)) {
|
||||
throw new Error(`Invalid filePath: ${req.query.filePath}`); // BAD crashes the server
|
||||
}
|
||||
// write content to disk
|
||||
}
|
||||
express().post("/save", (req, res) => {
|
||||
fs.exists(rootDir, (exists) => {
|
||||
if (!exists) {
|
||||
console.error(`Server setup is corrupted, ${rootDir} does not exist!`);
|
||||
res.status(500);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
save(rootDir, req.query.path, req.body);
|
||||
res.status(200);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// ...
|
||||
express().post("/save", (req, res) => {
|
||||
fs.exists(rootDir, (exists) => {
|
||||
// ...
|
||||
try {
|
||||
save(rootDir, req.query.path, req.body); // GOOD no uncaught exception
|
||||
res.status(200);
|
||||
res.end();
|
||||
} catch (e) {
|
||||
res.status(500);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
// ...
|
||||
express().post("/save", async (req, res) => {
|
||||
if (!(await fs.promises.exists(rootDir))) {
|
||||
console.error(`Server setup is corrupted, ${rootDir} does not exist!`);
|
||||
res.status(500);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
save(rootDir, req.query.path, req.body); // MAYBE BAD, depends on the commandline options
|
||||
res.status(200);
|
||||
res.end();
|
||||
});
|
||||
Reference in New Issue
Block a user