Correct way to find models by their association - sql

Both of these produce the same result:
User.where(account: 1)
User.where(account_id: 1)
But the generated SQL is different:
/* User.where(account: 1) */
SELECT "users".* FROM "users" WHERE "users"."account_id" = 1
/* User.where(account_id: 1) */
SELECT "users".* FROM "users" WHERE "users"."account_id" = $1 [["account_id", 1]]
Also both of these generate the same SQL as the first version:
a = Account.find(1)
User.where(account: a)
User.where(account_id: a)
# SELECT "users".* FROM "users" WHERE "users"."account_id" = 1
So which is the correct way to find a model by its association? Is the second version safer than the first? I tried to search for what's happening at the SQL level in the second version but I coudn't find anything.

There is no significant difference in your case. But if the account association is polymorphic, e.g. when there is a Business account and a Personal account, then the where(account: a) will generate something like WHERE account_type = 'Business' AND account_id = '123', while where(account_id: a) will generate just the WHERE account_id = '123'.

Related

ActiveRecord 'where not' and 'where' in same query

I would like to build a 'waiting_on' list where the users in the list meet two conditions: they do not belong to the current auction (User.where.not(auction: #auction)) and they do belong to the current_game (User.where(game: current_game).
How can I populate the array #waiting_on with users who meet both these requirements in ActiveRecord?
Here's my pseudocode attempt:
#waiting_on = User.where not(auction: #auction) and game: current_game
Update:
At the moment I have got it working like this, but it is a little ugly:
users_in_auction = User.where(auction: #auction)
users_in_game = User.where(game: current_game)
#waiting_on = users_in_game - users_in_auction
I have been trying this: User.where(game: current_game).where.not(auction: #auction), however the fact that the user that is not part of the auction has a nil value for auction_id seems to be messing it up. The SQL query output seemed to be exactly what I needed: SELECT "users".* FROM "users" WHERE "users"."game_id" = 3 AND ("users"."auction_id" != 2)
You can do it in one query by adding a null check:
User.where.not(auction: auction).or(User.where(auction: nil)).where(game: game)
This produces this SQL, which I think is what you want if I'm reading the question right:
SELECT "users".* FROM "users"
WHERE (("users"."auction_id" != 7) OR "users"."auction_id" IS NULL)
AND "users"."game_id" = 5

Make a request with activerecord to get only the users from groups and not from others

I'm trying to get users from few groups (with given ids) and exclude the users from other groups.
I've tried something like :
User.joins(:groups).where(groups: {id: ["8939","8950"]}).where.not(groups: {id: 8942}).map(&:id)
User Load (0.9ms) SELECT "users".* FROM "users" INNER JOIN "groups_users" ON "groups_users"."user_id" = "users"."id" INNER JOIN "groups" ON "groups"."id" = "groups_users"."group_id" WHERE "groups"."id" IN (8939, 8950) AND "groups"."id" != $1 [["id", 8942]]
=> [119491, 119489, 119490, 119492, 119488, 119484, 119483, 119491, 119482]
But that's not correct
The users in group 8942.
Group.find(8942).users.pluck(:id)
Group Load (0.4ms) SELECT "groups".* FROM "groups" WHERE "groups"."id" = $1 LIMIT 1 [["id", 8942]]
(0.6ms) SELECT "users"."id" FROM "users" INNER JOIN "groups_users" ON "users"."id" = "groups_users"."user_id" WHERE "groups_users"."group_id" = $1 [["group_id", 8942]]
=> [119490, 119492, 119491, 119457, 119423]
The where.not doesn't work on user "groups"."id" != $1 [["id", 8942]]. Why ?
Correct way to do such things is to use SQL EXISTS condition. I wish there was a specific ActiveRecord helper method for that, but there isn't at the moment.
Well, using pure SQL is just fine:
User.where("EXISTS (SELECT 1 FROM groups_users WHERE groups_users.user_id = users.id AND groups_users.group_id IN (?))", [8939, 8950]).
where("NOT EXISTS (SELECT 1 FROM groups_users WHERE groups_users.user_id = users.id AND groups_users.group_id IN (?))", [8942])
What you were doing with your original query is asking for not joining groups with [8942] ids to your query, and only joining groups with ids [8939, 8950]. Well, you can see right now that this doesn't make any sense: that's like asking to select every user whose name is bob and NOT charlie. Second condition doesn't add anything to the first one.
Join query is multiplicating columns, so if your user is in every group, result set would be:
user_id | group_id
1 | 8939
1 | 8950
1 | 8942
Then you filter out the latter row: 1 | 8942. Still, user 1 is in the result set and is returned.
And to ask the database to return only records which doesn't connect with another relation you should explicitly use NOT EXISTS which exists explicitly for that purpose :)
There is now a Where Exists gem which you can use. (Full disclosure: I've created that gem recently.)
With it you can achieve your task as simple as:
User.where_exists(:groups, id: [1, 2]).where_not_exists(:groups, id: [3, 4])

Get Users with params and without others

I'm trying to get group's users with specific ids that are not admin.
For the moment I have:
group.users
.joins(:roles)
.where(id: user_ids)
.where.not(roles: { role_type: Role::Type::ADMIN })
.pluck(:id)
In my log I have:
SQL to load the group:
(0.3ms) SELECT "users"."id" FROM "users" INNER JOIN "groups_users"
ON "users"."id" = "groups_users"."user_id"
WHERE "groups_users"."group_id" = $1 [["group_id", 137375]]
SQL for the query above:
(0.6ms) SELECT "users"."id" FROM "users" INNER JOIN "roles"
ON "roles"."user_id" = "users"."id" AND "roles"."is_destroyed" = $1
INNER JOIN "groups_users" ON "users"."id" = "groups_users"."user_id"
WHERE "groups_users"."group_id" = $2 AND "users"."id" IN (82884, 82885)
AND "roles"."role_type" != $3 [["is_destroyed", "f"],
["group_id", 137375], ["role_type", 1]]
The problem is I always get all the users of the group with matching user_ids. The where.not is not effective.
I had to do something like
users_in_group = group.users.where(id: user_ids).pluck(:id)
users_in_group -= group.users.joins(:roles).where
(roles: { role_type: Role::Type::ADMIN}).pluck(:id)
I don't understand why.
If you want to exclude Admins even if they have other roles, you might use SQL EXISTS:
group.users
.where(id: user_ids)
.where("NOT EXISTS (SELECT 1 FROM roles WHERE user_id = users.id AND role_type = ?", Role::Type::ADMIN)
.pluck(:id)
And, handling typical objection to such advice: it's perfectly fine to get your hands dirty by writing fragments of SQL when you are using ActiveRecord in Rails. You shouldn't limit yourself to the (not so broad) possibilities of its DSL.
UPD.
To simplify your code, you can use Where Exists gem (disclosure: I've written it recently).
Add gem 'where_exists' to your Gemfile, run bundle install, and then the following should work:
group.users
.where(id: user_ids)
.where_not_exists(:roles, role_type: Role::Type::ADMIN)
.pluck(:id)

Explain how order clause can be exploited in Rails

I am having difficulty understanding how this section from this website on Rails SQL Injections works.
Taking advantage of SQL injection in ORDER BY clauses is tricky, but a CASE statement can be used to test other fields, switching the sort column for true or false. While it can take many queries, an attacker can determine the value of the field.
Can someone explain? The bit where they say "switching the sort column for true or false" is the one that is hard to understand because I don't get how that would enable an attacker to reveal the value of another field.
If you are trying to determine the value of a field you know is in the table, but not being returned in the select you could iterate over it in the order by, until you get the value:
ORDER BY CASE WHEN variableIdLikeToDiscover < 'N' then 1 else 0 end
Then see whether it is greater than or less than 'N'. If it's less than, next you could try:
ORDER BY CASE WHEN variableIdLikeToDiscover < 'F' then 1 else 0 end
And so on and so forth until you have (eventually) determined the value.
The example shows that the :order parameter will be placed at the end of the statement, so if you add a comparison that is always true at the end, it will update all the rows.
For example, if you make a non-malicious order, it will be like:
params[:order] = "name"
User.update_all("admin = 1", "name LIKE 'B%'" , { :order => params[:order] })
The generated SQL will be:
UPDATE "users" SET admin = 1 WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE (name LIKE 'B%') ORDER BY name))
So, the update will be made on the users that have name LIKE 'B%'.
But, when the param is set to:
params[:order] = "name) OR 1=1;"
The generated SQL will be:
UPDATE "users" SET admin = 1 WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE (name LIKE 'B%') ORDER BY name) OR 1=1;)
Basically, an OR comparison will be added to the original WHERE, and the comparison will be: Update the users that have name LIKE 'B%' or 1=1. This will cause all the users to be update to admin=1 (in the given example).
Then the attacker can log in with any user an have admin privileges.
Hope it helps...

SQL Injection in ActiveRecord

I'm trying to build a vulnerable demo application. I'm using SQLite, and I have ruby code that looks like this:
#value = current_user.accounts.calculate(:sum, params[:column])
And the SQL generates the following by default:
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1 [["id", 1]]
(0.1ms) SELECT SUM("accounts"."account_value") AS sum_id FROM "accounts" WHERE "accounts"."user_id" = ? [["user_id", 1]]
Next, I put ssn) FROM users WHERE name = 'Texas'; -- into the form and I get the following:
(0.3ms) SELECT SUM(ssn) FROM users WHERE name = 'Texas'; --)) AS sum_id FROM "accounts" WHERE "accounts"."user_id" = ? [["user_id", 1]]
SQLite3::RangeException: bind or column index out of range: SELECT SUM(ssn) FROM users WHERE name = 'Texas'; --)) AS sum_id FROM "accounts" WHERE "accounts"."user_id" = ?
Completed 500 Internal Server Error in 2ms
ActiveRecord::StatementInvalid (SQLite3::RangeException: bind or column index out of range: SELECT SUM(ssn) FROM users WHERE name = 'Texas'; --)) AS sum_id FROM "accounts" WHERE "accounts"."user_id" = ?):
app/controllers/instant_calculator_controller.rb:3:in `sum'
I think the issue is that the 'user_id' section tacked onto the end as a paramiterized query is messing this up. I tried doing something like ssn) FROM users WHERE name = 'Texas'OR user_id = ?; -- just to throw that part of the query away, but that didn't seem to help.
Does anyone have any thoughts on I could make this work? I can change the code as well as the query, but I'd prefer to change the query before changing to code to make it SQLiable.
EDIT:
A bit more info. If I take the SQL that is generated and just change the last user_id to '1' so it looks like SELECT SUM(ssn) FROM users WHERE name = 'Texas'; --) AS sum_id FROM 'accounts' WHERE 'accounts'.'user_id' = 1 it works perfectly. I don't understand why this matters as everything after -- should be ignored.