Ask Ben: Limiting The Number Of Simultaneous Users For A Given IP Address
I have a question relating to the CF request cycle. This is because I am implementing some code which increments a number by IP address on each request start and decreases it on request end so that I may limit the amount of concurrent requests by IP every request start. That as a concept works fine although I was having weird issues where the request end seemingly did not fire. I then tracked this down to certain actions that caused the request end to not fire such as: <cfabort> and <cflocation>
I.e. the number by IP address would count up as the request started then something like a redirect would happen and the number would stay up, instead of counting back down. This effectively stacked up the 'current' requests of a user past what they were actually doing and once they reached the limit of say 10 concurrent by IP an error is thrown, effectively halting their access of the app. Would you have any suggestions of a way to ensure that request end is always fired or if not, the number can be decreased another way when one of these actions that excludes request end fires?
As you have seen, the fact that not all ColdFusion requests end in the same, uniform way makes incrementing and decrementing counts a problem. I have never had to do anything like this, but I can give you some off-the-top-of-my-head suggestions.
First, you could go around to all of the CFLocation tags and put some sort of pre-processing logic in place that would decrement the user counts before the CFLocation tag fires:
<!--- Lower user count. --->
<cfset UpdateUserCount() />
<!--- Redirect user to next page. --->
<cflocation url="index.cfm?go=contact.confirmation" />
This is a huge effort and one that I think is prone to problems; how easy would it be to forget to do that in one place? It'd be like creating a memory leak somewhere that is really hard to track down.
Another solution might be to create your own ColdFusion custom tag to wrap CFLocation:
<cf_location url="index.cfm?go=contact.confirmation" />
This custom location tag could centralize and encapsulate the user decrementing process before it internally executes a true ColdFusion CFLocation tag. This is a bit cleaner than the first suggestion, but it still seems as if it would be prone to error (people forgetting to use this custom tag instead of the actual CFLocation tag).
My last suggestion would be to completely abandon the idea of request-level user tracking. If we can afford to be slightly less accurate, I think the easier approach would be to turn this into time-based user tracking. Imagine that we had the following table to track users requests:
current_user
------------------------------
ip_address :: varchar
user_token :: varchar
date_created :: datetime
Now, imagine that we had this logic at the top of ever page request:
- Query current_user to see if we have reached the max user count per IP address in the previous two minutes.
- If Yes, then throw error.
- If No, then log user request and allow page load.
By making the security here time-based, we only have to check it at the top of the page - the bottom of the page, or the last execution of the page, no longer holds any value. The downside to this is that we now have to go to the database for every page request; however, if you keep this table small (it only needs to hold one to two minutes of data) and index it well, I don't think that that should be an issue.
I am not exactly sure what the query would look like, but I am thinking something like this (untested code):
<cfquery name="qIPCheck">
<!---
Occassionally thin out the database table. We are using
a random number here to make sure this doesn't happen on
every page request.
--->
<cfif (RandRange( 1, 10 ) EQ 5)>
<!--- Delete records more than 2 minutes old. --->
DELETE FROM
current_user
WHERE
date_created < <cfqueryparam value="#DateAdd( 'n', -2, Now() )#" cfsqltype="cf_sql_timestamp" />
</cfif>
<!---
Get the number of unique IP users have used this site in
the last two minutes (FOR THIS IP ADDRESS).
--->
SELECT
COUNT( * ) AS user_count
FROM
current_user
WHERE
date_created >= <cfqueryparam value="#DateAdd( 'n', -2, Now() )#" cfsqltype="cf_sql_timestamp" />
<!--- Limit to this IP address. --->
AND
ip_address = <cfqueryparam value="#CGI.remote_addr#" cfsqltype="cf_sql_varchar" />
<!---
We want to exclude the current user from this count as
he shouldn't weight in against his *own* page requests.
--->
AND
user_token != <cfqueryparam value="#SESSION.CFID#-#SESSION.CFTOKEN#" cfsqltype="cf_sql_varchar" />
GROUP BY
user_token
</cfquery>
While I can't test this, I believe that this query will check to see the number of unique users (not including the current) for a given IP address in the last two minutes. This value will now be held in:
qIPCheck.user_count
If this number is greater than the allowable number, then thrown an error. If the number is OK, then let the page load. If you are ok limiting the accuracy to one or two minutes, which doesn't seem too unreasonable to me, I think this is going to be the easiest, most centralized solution.
I hope that this helps in some way.
Want to use code from this post? Check out the license.
Reader Comments
I totally forgot to put this in there, but at the top of the request you also have to log the current user's request. I don't think it matters if you do this before or after the "user check" since you are filtering out the current user anyway.
Just wanted to point that out.
This is trivial to fix with an onError() handler. When you cfabort or cflocation CF throws a coldfusion.runtime.AbortException which you could trap in the Application.cfc#onError() and decrement the counter.
That aside, limiting connections based on IP address is a horrible horrible idea unless this application is 100% internal and not exposed on the internet.
Due to NAT thousands of computers can come across as the same IP address (schools, universities, offices, etc.). Certain ISPs also proxy all requests. For instance, AOL proxies all the requests from its users for "security reasons".
In general, some other means of limiting users is the way to go.
@Elliott,
Oh man! I forgot that onError() gets called before CFLocation. Brilliant idea man! Your wealth of knowledge is always inspiring.
WhosOnCFC would be pretty easy to modify to check for situations like that although it require writing a handler to check how many sessions are coming from a given IP address.
Maybe not the best solution, but it may help.
http://whosoncfc.riaforge.org/
As I replied to ben just after I asked the question, we ended up solving via a time based approach:
<cfcomponent ...>
...
<cfset variables.requestTimeframe = 10 />
<cfset variables.maxTimeframeRequestsByIp = 50 />
<cfset variables.requestsResetTime = 24 />
...
<cffunction name="onApplicationStart" ...>
...
<cfset application.requests = {} />
<cfset application.requestsStart = now() />
...
</cffunction>
<cffunction name="onRequestStart" ...>
<cfset var local = {} />
<cflock type="exclusive" name="appRequests" timeout="10">
<cfif dateDiff("H", application.requestsStart, now()) gt variables.requestsResetTime>
<cfset application.requests = {} />
<cfset application.requestsStart = now() />
</cfif>
</cflock>
<cflock type="exclusive" name="#cgi.REMOTE_ADDR#" timeout="10">
<cfif not structKeyExists(application.requests, cgi.REMOTE_ADDR)>
<cfset application.requests[cgi.REMOTE_ADDR] = [] />
</cfif>
<cfset local.i = 0 />
<cfset local.l = arrayLen(application.requests[cgi.REMOTE_ADDR]) />
<cfloop condition="true">
<cfset local.i = local.i++ />
<cfif local.i gt local.l>
<cfbreak />
</cfif>
<cfif dateDiff("S", application.requests[cgi.REMOTE_ADDR][local.i], now()) gt variables.requestTimeframe>
<cfset arrayDeleteAt(application.requests[cgi.REMOTE_ADDR], local.i)>
<cfset local.i = local.i-- />
<cfset local.l = local.l-- />
<cfelse>
<cfbreak />
</cfif>
</cfloop>
<cfset arrayAppend(application.requests[cgi.REMOTE_ADDR], now()) />
<cfif arrayLen(application.requests[cgi.REMOTE_ADDR]) gt variables.maxTimeframeRequestsByIp>
<cfthrow type="maxConcurrentRequestsByIpExceeded" message="Your IP address: #cgi.REMOTE_ADDR# has exceeded the maximum number of requests within the allowed time frame." />
</cfif>
</cflock>
...
</cffunction>
...
<cfcomponent ...>
This time based approch does not rely on the database for the reasons that you described Ben, but does only use reqyest start. The first section deals with clearing the request memory every certain amount of hours (24), then it will allow a certain amount of requests (50) from the same IP to access the request within the given timeframe (10 secounds). This means 5 requests per second i.e. 5 _real_ users approx.
This approach allows us to scale requests in the timeframe depending on traffic, but it was essential to do this because a number of bots were hitting the server some sort of DoS attack that was crashing it.
By keeping the amount of requests and also the time before reset scaled low it means that even from different IPs it will be alot harder for this to occur.
If there is a better way we are all ears :)
On a slightly different note, CF is dumb IMO to throw an error when relocating as that is _not_ an exception >.<
Also onRequestEnd should either be removed _or_ fixed, as it is basically useless atm. This is becasue you know you cannot put code in there and expect it to run always as it only runs for certain request cycles. I am assuming it is not possible to have it run in other cases, so it may as well not be there at all.
As far as Im concerned as soon as anything halts/re-locates the page request, that is "request end".
Regards
shuns
Yes the cflocation is an interesting problem as it means your not firing the onrequestend. I tend to avoid cflocation in almost all instances these days anyway, in theory you should be able to be a switch to any other page though your centeral index.cfm pretty easy. (im assuming everyone runs through a central page these days)
C boy