Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a510a7b
Add insecure direct object reference definitions and factor out thos…
joefarebrother Aug 1, 2023
5d12896
Add IDOR query
joefarebrother Aug 3, 2023
251f875
Fix filenme typo
joefarebrother Aug 3, 2023
2edd73e
Fix typos in filepath + metadata, add severity
joefarebrother Aug 4, 2023
20d42df
Add tests for webforms case
joefarebrother Aug 14, 2023
009a7bf
Add MVC tests
joefarebrother Aug 17, 2023
f8b1b38
Update alert message and make user checks more precise
joefarebrother Aug 17, 2023
3e6750b
Add documentation
joefarebrother Aug 21, 2023
4967fe0
Add change note + update query ID
joefarebrother Aug 21, 2023
9f25c71
Apply minor reveiw suggstions
joefarebrother Aug 24, 2023
86abd33
Update test options
joefarebrother Aug 24, 2023
a022893
Add additional example to qhelp + additional resource
joefarebrother Aug 25, 2023
0a27da0
Minor changes from review suggestions to shared logic between this an…
joefarebrother Aug 25, 2023
ac45050
Add checks for authorization attributes
joefarebrother Sep 11, 2023
6a95ed6
Add test cases for authorization from attributes
joefarebrother Sep 13, 2023
a2dce6b
Check for authorize attributes in more namespaces and on overridden m…
joefarebrother Sep 14, 2023
6d704be
Rewrite checks for index expressions in terms of dataflow
joefarebrother Sep 14, 2023
68ad5b7
Restrict logic for checking for id parameters on index expressions fo…
joefarebrother Sep 15, 2023
eb2f589
Fix typos
joefarebrother Sep 15, 2023
868836e
Update severity
joefarebrother Sep 15, 2023
475fe3a
Attempt to improve performance in checksUser
joefarebrother Sep 18, 2023
4497e22
Add an additional example and additional test cases for authorize att…
joefarebrother Sep 20, 2023
df5fcc9
Apply suggestions from docs review
joefarebrother Sep 25, 2023
3efbbb3
Elaborate 'guess' to 'guess or determine'
joefarebrother Sep 25, 2023
d7c1be4
Fix codescanning alert by tweaking imported modules
joefarebrother Sep 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions csharp/ql/lib/semmle/code/csharp/security/auth/ActionMethods.qll
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/** Common definitions for queries checking for access control measures on action methods. */

import csharp
import semmle.code.csharp.frameworks.microsoft.AspNetCore
import semmle.code.csharp.frameworks.system.web.UI

/** A method representing an action for a web endpoint. */
abstract class ActionMethod extends Method {
/**
* Gets a string that can indicate what this method does to determine if it should have an auth check;
* such as its method name, class name, or file path.
*/
string getADescription() {
result = [this.getName(), this.getDeclaringType().getBaseClass*().getName(), this.getARoute()]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changed slightly when it was moved. Now this.getARoute() is being used instead of this.getDeclaringType().getFile().getRelativePath() (which is the same as long as getARoute is not overridden).
Is this a bug fix (this might change the description of WebFormActionMethod elements)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an intended fix (the route to a method should also contribute to knowledge about the names referring to that method to help determine what it does)

}

/** Holds if this method may represent a stateful action such as editing or deleting */
predicate isEdit() {
exists(string str |
str =
this.getADescription()
// separate camelCase words
.regexpReplaceAll("([a-z])([A-Z])", "$1_$2") and
str.regexpMatch("(?i).*(edit|delete|modify|change).*") and
Comment thread
mbg marked this conversation as resolved.
not str.regexpMatch("(?i).*(on_?change|changed).*")
)
}

/** Holds if this method may be intended to be restricted to admin users */
predicate isAdmin() {
this.getADescription()
// separate camelCase words
.regexpReplaceAll("([a-z])([A-Z])", "$1_$2")
.regexpMatch("(?i).*(admin|superuser).*")
}

/** Gets a callable for which if it contains an auth check, this method should be considered authenticated. */
Callable getAnAuthorizingCallable() { result = this }

/**
* Gets a possible url route that could refer to this action,
* which would be covered by `<location>` configurations specifying a prefix of it.
*/
string getARoute() { result = this.getDeclaringType().getFile().getRelativePath() }
}

/** An action method in the MVC framework. */
private class MvcActionMethod extends ActionMethod {
MvcActionMethod() { this = any(MicrosoftAspNetCoreMvcController c).getAnActionMethod() }
}

/** An action method on a subclass of `System.Web.UI.Page`. */
private class WebFormActionMethod extends ActionMethod {
WebFormActionMethod() {
this.getDeclaringType().getBaseClass+() instanceof SystemWebUIPageClass and
this.getAParameter().getType().getName().matches("%EventArgs")
}

override Callable getAnAuthorizingCallable() {
result = super.getAnAuthorizingCallable()
or
pageLoad(result, this.getDeclaringType())
}

override string getARoute() {
exists(string physicalRoute | physicalRoute = super.getARoute() |
result = physicalRoute
or
exists(string absolutePhysical |
virtualRouteMapping(result, absolutePhysical) and
physicalRouteMatches(absolutePhysical, physicalRoute)
)
)
}
}

pragma[nomagic]
private predicate pageLoad(Callable c, Type decl) {
c.getName() = "Page_Load" and
decl = c.getDeclaringType()
}

/**
* Holds if `virtualRoute` is a URL path
* that can map to the corresponding `physicalRoute` filepath
* through a call to `MapPageRoute`
*/
private predicate virtualRouteMapping(string virtualRoute, string physicalRoute) {
exists(MethodCall mapPageRouteCall, StringLiteral virtualLit, StringLiteral physicalLit |
mapPageRouteCall
.getTarget()
.hasQualifiedName("System.Web.Routing", "RouteCollection", "MapPageRoute") and
virtualLit = mapPageRouteCall.getArgument(1) and
physicalLit = mapPageRouteCall.getArgument(2) and
virtualLit.getValue() = virtualRoute and
physicalLit.getValue() = physicalRoute
)
}

/** Holds if the filepath `route` can refer to `actual` after expanding a '~". */
bindingset[route, actual]
private predicate physicalRouteMatches(string route, string actual) {
route = actual
or
route.charAt(0) = "~" and
exists(string dir | actual = dir + route.suffix(1) + ".cs")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** Definitions for the Insecure Direct Object Reference query */

import csharp
import semmle.code.csharp.dataflow.flowsources.Remote
import ActionMethods

/**
* Holds if `m` is a method that may require checks
* against the current user to modify a particular resource.
*/
// We exclude admin methods as it may be expected that an admin user should be able to modify any resource.
// Other queries check that there are authorization checks in place for admin methods.
private predicate needsChecks(ActionMethod m) { m.isEdit() and not m.isAdmin() }

/**
* Holds if `m` has a parameter or access a remote flow source
* that may indicate that it's used as the ID for some resource
*/
private predicate hasIdParameter(ActionMethod m) {
exists(RemoteFlowSource src | src.getEnclosingCallable() = m |
src.asParameter().getName().toLowerCase().matches(["%id", "%idx"])
or
// handle cases like `Request.QueryString["Id"]`
exists(StringLiteral idStr, IndexerCall idx |
idStr.getValue().toLowerCase().matches(["%id", "%idx"]) and
TaintTracking::localTaint(src, DataFlow::exprNode(idx.getQualifier())) and
DataFlow::localExprFlow(idStr, idx.getArgument(0))
)
)
}

private predicate authorizingCallable(Callable c) {
exists(string name | name = c.getName().toLowerCase() |
name.matches(["%user%", "%session%"]) and
not name.matches("%get%by%") // methods like `getUserById` or `getXByUsername` aren't likely to be referring to the current user
)
}

/** Holds if `m` at some point in its call graph may make some kind of check against the current user. */
private predicate checksUser(ActionMethod m) {
exists(Callable c |
authorizingCallable(c) and
callsPlus(m, c)
)
}

private predicate calls(Callable c1, Callable c2) { c1.calls(c2) }

private predicate callsPlus(Callable c1, Callable c2) = fastTC(calls/2)(c1, c2)

/** Holds if `m`, its containing class, or a parent class has an attribute that extends `AuthorizeAttribute` */
private predicate hasAuthorizeAttribute(ActionMethod m) {
exists(Attribute attr |
attr.getType()
.getABaseType*()
.hasQualifiedName([
"Microsoft.AspNetCore.Authorization", "System.Web.Mvc", "System.Web.Http"
], "AuthorizeAttribute")
|
attr = m.getOverridee*().getAnAttribute() or
attr = m.getDeclaringType().getABaseType*().getAnAttribute()
)
}

/** Holds if `m`, its containing class, or a parent class has an attribute that extends `AllowAnonymousAttribute` */
private predicate hasAllowAnonymousAttribute(ActionMethod m) {
exists(Attribute attr |
attr.getType()
.getABaseType*()
.hasQualifiedName([
"Microsoft.AspNetCore.Authorization", "System.Web.Mvc", "System.Web.Http"
], "AllowAnonymousAttribute")
|
attr = m.getOverridee*().getAnAttribute() or
attr = m.getDeclaringType().getABaseType*().getAnAttribute()
)
}

/** Holds if `m` is authorized via an `Authorize` attribute */
private predicate isAuthorizedViaAttribute(ActionMethod m) {
hasAuthorizeAttribute(m) and
not hasAllowAnonymousAttribute(m)
Comment on lines +81 to +82

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering about the correctness of this predicate with different permutations of these attributes in the inheritance chain. E.g. suppose you have a base class with a method that has the AllowAnonymous attribute and then override that method in a child class, where you give it an explicit Authorize attribute. What effect would that have in ASP.NET? Does this predicate correctly model that behaviour?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm; I'm not sure what ASP.NET would do in that situation.
How much do we need to be concerned with correctly covering every edge case? As it seems like these kinds of situations are unlikely to come up in real codebases.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know off the top of my head what ASP.NET would do in that case, so I'd need to try it with a sample project as well. I am happy if we postpone looking into this and don't block this PR on addressing this detail.

}

/**
* Holds if `m` is a method that modifies a particular resource based on
* an ID provided by user input, but does not check anything based on the current user
* to determine if they should modify this resource.
*/
predicate hasInsecureDirectObjectReference(ActionMethod m) {
needsChecks(m) and
Comment thread
mbg marked this conversation as resolved.
hasIdParameter(m) and
not checksUser(m) and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am concerned by the rate at which this may produce false positives. My understanding is that e.g. in ASP.NET the standard mechanism for managing authentication/authorisation is through attributes. The query does not seem to handle those (as well as auth code inherited from parent classes / any other form of auth), so would this query trigger alerts in those cases?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are authorization attributes alone able to manage authorization that's based on the request parameters?
In these MRVA results I don't see a lot of evidence of other auth mechanisms that were missed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, using attributes is only sufficient where authorisation is based on roles, policies, etc.

For resource-based authorization checks in the body of the handler are indeed necessary. Would the example in that article be identified by checksUser?

However, role-based or policy-based authorisation may be sufficient in a lot of cases, so we probably shouldn't flag up insecure direct object reference vulnerabilities where checks are performed in that way.

Based on your description of the MRVA experiment, it seems like the results are only for the top 100 repos? Are those the top 100 C# repos or the top 100 repos that use ASP.NET? In any case, I would probably use a larger sample. It is also likely that the different authorisation mechanisms offered by ASP.NET are more prevalent in enterprise codebases, rather than in open-source ones.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the example in the article would be identified by checksUser, because it uses the User property.
This MRVA run with the checksUser condition reversed shows the auth methods that are being used.

However in this top 1000 MRVA run, it looks like there are indeed some FPs from role-based authentication for admin roles; so this is something that should be checked.
Should all instances of the Authorize attribute with Roles or Policy fields set be considered sufficient, or should a more complex check be made?

@mbg mbg Aug 24, 2023

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because it uses the User property

Ah, I don't think I had realised that properties count as Callables.

However in this top 1000 MRVA run, it looks like there are indeed some FPs from role-based authentication for admin roles; so this is something that should be checked.

Thank you for running that experiment! It does indeed seem to confirm my concerns here.

Should all instances of the Authorize attribute with Roles or Policy fields set be considered sufficient, or should a more complex check be made?

I think the logic is a little bit more complicated because the Authorize attribute may be placed on the method, class, or parent classes(?). So you need to check in all of these places for that attribute as well as any attributes which inherit from Authorize. Furthermore, you also need to check for the absence of the AllowAnonymous attribute, which may override any Authorize-like attribute that's placed further out. That's just off the top of my head, so there may be further subtleties. Microsoft's documentation should have the full details.

In terms of the arguments to the Authorize attribute, I don't think we should care at all. As long as there is some authorisation check, that should be considered a mitigation for the purpose of this query since we can't reason about what the intended audience for a particular endpoint is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is my understanding that an Authorize attribute with no arguments means "Any logged in user can access this", whereas this query focuses on cases where we'd expect stronger access controls than that (e.g. deleting any resource). Is this correct?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, although consider that there might be web applications where most visitors have no accounts and the only authorised users you'll get are administrators that should be able to perform such actions. (E.g. a blog, a simple CMS/news website, etc.) In those cases just being logged in may be sufficient access control.

Also, for classes that inherit from Authorize, we wouldn't necessarily expect any parameters either, e.g.:

class MyAuthorizeAttribute : AuthorizeAttribute
{
    public MyAuthorizeAttribute()
    {
        base.Policy = "Administrators";
    }
}

not isAuthorizedViaAttribute(m) and
exists(m.getBody().getAChildStmt())
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,96 +4,10 @@ import csharp
import semmle.code.csharp.frameworks.microsoft.AspNetCore
import semmle.code.csharp.frameworks.system.web.UI
import semmle.code.asp.WebConfig
import ActionMethods

/** A method representing an action for a web endpoint. */
abstract class ActionMethod extends Method {
/**
* Gets a string that can indicate what this method does to determine if it should have an auth check;
* such as its method name, class name, or file path.
*/
string getADescription() {
result =
[
this.getName(), this.getDeclaringType().getBaseClass*().getName(),
this.getDeclaringType().getFile().getRelativePath()
]
}

/** Holds if this method may need an authorization check. */
predicate needsAuth() {
this.getADescription()
.regexpReplaceAll("([a-z])([A-Z])", "$1_$2")
// separate camelCase words
.toLowerCase()
.regexpMatch(".*(edit|delete|modify|admin|superuser).*")
}

/** Gets a callable for which if it contains an auth check, this method should be considered authenticated. */
Callable getAnAuthorizingCallable() { result = this }

/**
* Gets a possible url route that could refer to this action,
* which would be covered by `<location>` configurations specifying a prefix of it.
*/
string getARoute() { result = this.getDeclaringType().getFile().getRelativePath() }
}

/** An action method in the MVC framework. */
private class MvcActionMethod extends ActionMethod {
MvcActionMethod() { this = any(MicrosoftAspNetCoreMvcController c).getAnActionMethod() }
}

/** An action method on a subclass of `System.Web.UI.Page`. */
private class WebFormActionMethod extends ActionMethod {
WebFormActionMethod() {
this.getDeclaringType().getBaseClass+() instanceof SystemWebUIPageClass and
this.getAParameter().getType().getName().matches("%EventArgs")
}

override Callable getAnAuthorizingCallable() {
result = super.getAnAuthorizingCallable()
or
result.getDeclaringType() = this.getDeclaringType() and
result.getName() = "Page_Load"
}

override string getARoute() {
exists(string physicalRoute | physicalRoute = super.getARoute() |
result = physicalRoute
or
exists(string absolutePhysical |
virtualRouteMapping(result, absolutePhysical) and
physicalRouteMatches(absolutePhysical, physicalRoute)
)
)
}
}

/**
* Holds if `virtualRoute` is a URL path
* that can map to the corresponding `physicalRoute` filepath
* through a call to `MapPageRoute`
*/
private predicate virtualRouteMapping(string virtualRoute, string physicalRoute) {
exists(MethodCall mapPageRouteCall, StringLiteral virtualLit, StringLiteral physicalLit |
mapPageRouteCall
.getTarget()
.hasQualifiedName("System.Web.Routing", "RouteCollection", "MapPageRoute") and
virtualLit = mapPageRouteCall.getArgument(1) and
physicalLit = mapPageRouteCall.getArgument(2) and
virtualLit.getValue() = virtualRoute and
physicalLit.getValue() = physicalRoute
)
}

/** Holds if the filepath `route` can refer to `actual` after expanding a '~". */
bindingset[route, actual]
private predicate physicalRouteMatches(string route, string actual) {
route = actual
or
route.charAt(0) = "~" and
exists(string dir | actual = dir + route.suffix(1) + ".cs")
}
/** Holds if the method `m` may need an authorization check. */
predicate needsAuth(ActionMethod m) { m.isEdit() or m.isAdmin() }

/** An expression that indicates that some authorization/authentication check is being performed. */
class AuthExpr extends Expr {
Expand All @@ -114,7 +28,7 @@ class AuthExpr extends Expr {

/** Holds if `m` is a method that should have an auth check, and does indeed have one. */
predicate hasAuthViaCode(ActionMethod m) {
m.needsAuth() and
needsAuth(m) and
exists(Callable caller, AuthExpr auth |
m.getAnAuthorizingCallable().calls*(caller) and
auth.getEnclosingCallable() = caller
Expand Down Expand Up @@ -175,7 +89,7 @@ predicate hasAuthViaAttribute(ActionMethod m) {

/** Holds if `m` is a method that should have an auth check, but is missing it. */
predicate missingAuth(ActionMethod m) {
m.needsAuth() and
needsAuth(m) and
not hasAuthViaCode(m) and
not hasAuthViaXml(m) and
not hasAuthViaAttribute(m) and
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE qhelp PUBLIC
"-//Semmle//qhelp//EN"
"qhelp.dtd">
<qhelp>
<overview>
<p>Resources like comments or user profiles can be accessed and modified through an action method. To target a certain resource, the action method accepts an ID parameter pointing to that specific resource. If the methods do not check that the current user is authorized to access the specified resource, an attacker can access a resource by guessing or otherwise determining the linked ID parameter.</p>

</overview>
<recommendation>
<p>
Ensure that the current user is authorized to access the resource of the provided ID.
</p>

</recommendation>
<example>
<p>In the following example, in the "BAD" case, there is no authorization check, so any user can edit any comment for which they guess or determine the ID parameter.
The "GOOD" case includes a check that the current user matches the author of the comment, preventing unauthorized access.</p>
<sample src="WebFormsExample.cs" />
<p>The following example shows a similar scenario for the ASP.NET Core framework. As above, the "BAD" case provides an example with no authorization check, and the first "GOOD" case provides an example with a check that the current user authored the specified comment. Additionally, in the second "GOOD" case, the `Authorize` attribute is used to restrict the method to administrators, who are expected to be able to access arbitrary resources.</p>
<sample src="MVCExample.cs" />

</example>
<references>

<li>OWASP: <a href="https://wiki.owasp.org/index.php/Top_10_2013-A4-Insecure_Direct_Object_References">Insecure Direct Object Refrences</a>.</li>
<li>OWASP: <a href="https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References">Testing for Insecure Direct Object References</a>.</li>
<li>Microsoft Learn: <a href="https://learn.microsoft.com/en-us/aspnet/core/security/authorization/resourcebased?view=aspnetcore-7.0">Resource-based authorization in ASP.NET Core</a>.</li>

</references>
</qhelp>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @name Insecure Direct Object Reference
* @description Using user input to control which object is modified without
* proper authorization checks allows an attacker to modify arbitrary objects.
* @kind problem
* @problem.severity warning
* @security-severity 7.5
* @precision medium
* @id cs/web/insecure-direct-object-reference
* @tags security
* external/cwe-639
*/

import csharp
import semmle.code.csharp.security.auth.InsecureDirectObjectReferenceQuery

from ActionMethod m
where hasInsecureDirectObjectReference(m)
select m,
"This method may be missing authorization checks for which users can access the resource of the provided ID."
Loading