-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Ruby: Rails route resolution #7061
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
4e858e9 to
01b51c4
Compare
01b51c4 to
2fe011f
Compare
9820a8e to
0fdbe22
Compare
nickrolfe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like it was a ton of work. I've only got part-way through reviewing this, but it looks great!
|
|
||
| override string getAction() { result = action } | ||
|
|
||
| override string getHTTPMethod() { result = httpMethod } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed this returning patch for a call like this:
resources :foo,
path: "/foo/:foo_id/bar",
only: %w(new create edit update destroy),
...There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh wait, am I mixing up actions and HTTP methods?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PATCH is a valid, if rarely used, HTTP method which acts similarly to PUT, but is intended for partial updates to a resource (whereas PUT is for completely replacing the resource). Rails prefers PATCH for updates because it is a better fit semantically. (https://guides.rubyonrails.org/v6.0/4_0_release_notes.html)
2989cab to
5e9b250
Compare
|
The tests are failing because of a warning about the Otherwise, LGTM. |
183a1bb to
aa96717
Compare
aa96717 to
0fd3e46
Compare
alexrford
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is really nice - very clearly structured and well commented. Only a handful of minor comments.
| result = method.getKeywordArgument("to").(StringlikeLiteral).getValueText() | ||
| or | ||
| method.getKeywordArgument("to").(MethodCall).getMethodName() = "redirect" and | ||
| result = "<redirect>#<redirect>" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a placeholder value?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took me a while to remember what this is for! It covers cases like
get "/stories" => redirect("/posts")
In this case there's no controller/action combo that we can map GET /stories to, except by trying to resolve /posts to a route and going from there. That seemed like a lot of effort so instead we just return <redirect>#<redirect> for the action string.
Thinking about it again, perhaps we should just return no result in this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's probably okay as it is - by doing this it looks like getAction() will return "<redirect>", so that's a bit more informative than having no result.
|
|
||
| override string getHTTPMethod() { | ||
| result = method.getKeywordArgument("via").(StringlikeLiteral).getValueText() or | ||
| result = method.getKeywordArgument("via").(ArrayLiteral).getElement(_).getValueText() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wonder if this should have a special case for :all where the result = ["get", "post", "put", "patch", "delete"] (https://guides.rubyonrails.org/routing.html#http-verb-constraints)?
0fd3e46 to
7369846
Compare
alexrford
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hmac just one last comment, let me know when it's done and I'll approve this PR.
| | | ||
| via = "all" and result = anyHttpMethod() | ||
| or | ||
| via != "all" and result = "via" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| via != "all" and result = "via" | |
| via != "all" and result = via |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤦 good catch!
hvitved
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few drive-by comments.
| ActionDispatch::Route getARoute() { | ||
| result.getController() + "_controller" = | ||
| ActionDispatch::underscore(namespaceDeclaration(controllerClass)) and | ||
| this.getName() = result.getAction() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like a candidate for a bad join, but I don't know if joining on getName() and getAction() alone can yield a large set in practice. It should be possible to join simultaneously on name and controller, see e.g. #7738 for an example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm still getting my head around this problem and its mitigation. I've replaced this line with a call to isActionControllerMethod(this, result.getAction(), controllerClass) (749dc09), which will hopefully force a join simultaneously on getName(), getAction() and controllerClass. Is that enough to protect against this issue or do we need to do something else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you add a new predicate
pragma[nomagic]
private predicate isRoute(
ActionDispatch::Route r, string name, ActionControllerControllerClass controllerClass
) {
r.getController() + "_controller" =
ActionDispatch::underscore(namespaceDeclaration(controllerClass)) and
name = r.getAction()
}and change the body to
exists(string name |
isRoute(result, name, controllerClass) and
isActionControllerMethod(this, name, controllerClass)
)then simultaneous joining on name and controllerClass should be ensured.
Add `Route` classes which model Rails routing information, typically defined in a `routes.rb` file. We extract only the most basic information: HTTP method, path, controller and action. This is enough to determine whether a given controller method is a route handler, and what HTTP method it handles, which is useful for, among other things, the URL redirect query.
Handlers for non-GET requests aren't vulnerable to URL redirect attacks, because browsers won't initiate non-GET requests when you click a link. We can use Rails routing information, if present, to filter out any handlers for non-GET requests.
This predicate isn't used.
This shows how the predicate behaves, as well as a case where it goes wrong.
Any classes/predicates not used externally or in tests are now private. Also fix some typos.
This version is much shorter and hopefully performs a bit better.
ActionDispatch modelling now understands that
match "/foo", to: "foo#bar", via: :all
is equivalent to
match "/foo",
to: "foo#bar",
via: [:get, :post, :put, :patch, :delete]
`x.isStringOrSymbol(result)` is slightly terser than `result = x.getStringOrSymbol()`.
By joining simultaneously on controller class and name.
Make ActionDispatch::Route into a private class ActionDispatch::RouteImpl, defining a new class Route which exposes the necessary public API from RouteImpl. Also rename getHTTPMethod to getHttpMethod.
c94f4c1 to
80835a5
Compare
| } | ||
| } | ||
|
|
||
| private predicate isRoute( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest adding pragma[nomagic] to ensure that the compiler does not mess up our optimization (e.g. by inlining the predicate)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done


Add
Routeclasses which model Rails routing information, typicallydefined in a
routes.rbfile. We extract only the most basicinformation: HTTP method, path, controller and action. This is enough to
determine whether a given controller method is a route handler, and what
HTTP method it handles, which is useful for, among other things, the URL
redirect query.
This is quite a big PR with some probably-questionable design decisions, but does work correctly at least. I'd appreciate feedback on a) whether this is worth it and b) if there's a
more concise/more performant way to do it.