Simple Non-ColdFusion Document Security For Shared Hosting Environments
At the last New York CFUG meeting, someone brought up the topic of document security - how do you stop people from accessing non-ColdFusion documents (ie. MS Word, Text Files, etc)? Another person at the meeting suggested putting them in a non-web-accessible directory and then either copying them to a public folder as needed or streaming them using the ColdFusion CFContent. This, however, was not an option because the person asking the question was on some sort of shared hosting environment where he did not have access to any other directories but the webroot of his site.
My question/suggestion was then, why not just make everything a ColdFusion document? Think about it - the only reason that we cannot secure a word document is because it is not going through the ColdFusion application server (it's being handled directly by the web server). If, however, we turned all uploaded documents into ColdFusion documents, then suddenly, we would have total access control.
So, how do you make a non-ColdFusion document into a ColdFusion document? Easy, slap a ".cfm" onto the end of it. I assume that this sort of thing could be done on upload to the server (whether via FTP or some secured user-interface). Once, all of the secured documents have a .cfm extension, then it's just a matter of controlling access and streaming files.
To test this out, I set up a very small application that lists out and links to the documents in a directory called, "documents." My business rule for this application was that all files inside of the documents folder were securied and required a login. This directory looked like this:
Document1.txt.cfm
Document2.txt.cfm
Girl Brunette.jpg.cfm
Manual.doc.cfm
Notice that all the documents end in .cfm and realize that any direct access to them will invoke the ColdFusion application server (that is the magic going on here).
Then, I created an index.cfm file that queried this folder and listed out the files:
<cfoutput>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<title>Document System</title>
</head>
<body>
<!---
Get the documents listed in the documents folder.
All of these documents are locked using CFM, so
only get files that have CFM extension.
--->
<cfdirectory
action="list"
directory="#ExpandPath( './documents/' )#"
filter="*.cfm"
name="qFile"
/>
<!---
Output the file list and allow the user to link
to each document directly.
--->
<cfloop query="qFile">
<!---
Get the display name. We don't want CFM
to show up in the output cause it might
confuse the user.
--->
<cfset strName = REReplace(
qFile.name,
"\.cfm$",
"",
"ONE"
) />
<p>
<a href="./documents/#qFile.name#">#strName#</a>
</p>
</cfloop>
</body>
</html>
</cfoutput>
The thing to be careful of here is that you don't want to list the files with a .CFM file extension as it might confuse the user (and the downloaded file name will be different). Therefore, when outputing the query, I get a "clean" version of the name with the final extension stripped out. Of course, the link HREF itself still needs to link to the actual file (.cfm extension included).
The security logic then gets implemented in the ColdFusion Application.cfc component. If the Application.cfc has the OnRequest() event method defined, then ColdFusion gives us the ability to examine each requested template as the page requests come in. This is the perfect scenario - we know what folder is secured, we know each item that get's requested - it's just a matter securing all file requests made to the documents folder.
<cfcomponent
output="false"
hint="Handles the application level events.">
<!--- Set application settings. --->
<cfset THIS.Name = "GhettoSecurityDemo" />
<cfset THIS.ApplicationTimeout = CreateTimeSpan( 0, 0, 0, 10 ) />
<cfset THIS.SessionManagement = false />
<cfset THIS.SetClientCookies = false />
<!--- Set page request setttings. --->
<cfsetting
requesttimeout="20"
showdebugoutput="false"
enablecfoutputonly="true"
/>
<cffunction
name="OnRequest"
access="public"
returntype="void"
output="true"
hint="Fires when a template needs to be executed (after pre-page processing).">
<!--- Define arguments. --->
<cfargument
name="TargetPage"
type="string"
required="true"
hint="The template that was requrested in the URL."
/>
<!--- Define the local scope. --->
<cfset var LOCAL = StructNew() />
<!---
Get the directory in which the requested target
template is living. This will help us to determine
if this is a template that we shoudl be securing.
--->
<cfset LOCAL.Directory = ListLast(
GetDirectoryFromPath( ARGUMENTS.TargetPage ),
"\/"
) />
<!---
Check to see if this file is in the documents
directory. All documents within the documents
directory require the user to be logged in.
--->
<cfif (LOCAL.Directory EQ "documents")>
<!---
We know that they are trying access a secure
document since they are in the secure documents
folder. Now, we need to see if they have access
to this folder.
--->
<cfif NOT IsLoggedIn()>
<!---
The user does not have access to download
these files. Output an error message and
halt page request.
--->
<cfoutput>
Access Denied
</cfoutput>
<!--- Return out. --->
<cfreturn />
</cfif>
<!---
ASSERT: At this point, we know that this user
as security permissions to access this file.
--->
<!--- Get the file name of the document. --->
<cfset LOCAL.SecureFileName = GetFileFromPath(
ARGUMENTS.TargetPage
) />
<!---
Get the name of the file as it exists without
the .CFM security extension.
--->
<cfset LOCAL.FileName = REReplace(
LOCAL.SecureFileName,
"\.cfm$",
"",
"ONE"
) />
<!---
Set the header using the clean file name. Be
sure to double-quote the file name as we might
have files with spaces.
--->
<cfheader
name="content-disposition"
value="attachment; filename=""#LOCAL.FileName#"""
/>
<!---
Stream the file. When streaming the file, use
the .CFM file name, not the clean file name
(otherwise, the server won't be able to find it).
--->
<cfcontent
type="#GetMimeType( LOCAL.FileName )#"
file="#ExpandPath( ARGUMENTS.TargetPage )#"
/>
<!---
Since we are serving up a file, we don't
have to worry about the OnRequestEnd() firing
(I don't think).
--->
<cfelse>
<!---
The file is not in the documents directory, so
it really doesn't much concern us. Just let it
execute normally.
--->
<cfinclude template="#ARGUMENTS.TargetPage#" />
</cfif>
<!--- Return out. --->
<cfreturn />
</cffunction>
<cffunction
name="GetMimeType"
access="public"
returntype="string"
output="false"
hint="Returns the suggested mimetype for the given file name.">
<!--- Define arguments. --->
<cfargument
name="Name"
type="string"
required="true"
hint="The file name for which we want the most appropriate mime type."
/>
<!--- Get the extension. --->
<cfset var strExt = ListLast( ARGUMENTS.Name, "." ) />
<!--- Return the mime type based on the file ext. --->
<cfswitch expression="#strExt#">
<cfcase value="jpg,jpeg,jpe" delimiters=",">
<cfreturn "image/jpeg" />
</cfcase>
<cfcase value="gif,png,bmp,tiff" delimiters=",">
<cfreturn "image/#strExt#" />
</cfcase>
<cfcase value="doc,rtf,mht" delimiters=",">
<cfreturn "application/msword" />
</cfcase>
<cfcase value="xls,csv" delimiters=",">
<cfreturn "application/msexcel" />
</cfcase>
<cfcase value="txt" delimiters=",">
<cfreturn "text/plain" />
</cfcase>
<cfcase value="htm,html" delimiters=",">
<cfreturn "text/html" />
</cfcase>
<!---
If nothing else seems appropriate,
return the catch-all octet stream.
--->
<cfdefaultcase>
<cfreturn "application/octet-stream" />
</cfdefaultcase>
</cfswitch>
</cffunction>
<cffunction
name="IsLoggedIn"
access="public"
returntype="boolean"
output="false"
hint="Determins if the request is being made by a logged-in user.">
<!---
Since we dont have session management
turned on (and this is just a demo),
just return a random boolean.
--->
<cfreturn RandRange( 0, 1 ) />
</cffunction>
</cfcomponent>
Since I don't have any session management turned on, I just randomly select whether the user has access to the documents folder at the time of the given request. If they do have access, then I don't execute the requested template (the secured file), I stream it back to the user using CFHeader / CFContent. I am not the hugest fan of streaming files, but it's probably the easiest way to implement security with the least amount of logic. If they don't have access, then I just return an "Access Denied" string and return out of the OnRequest() method.
Due to the fact that we are streaming the file, we have to figure out the MIME type of the document at run time. To do this, I am doing very basic file extension evaluation. This seems to work fine, but you run the chance of streaming a file whose file extension is not known to you. Then, even if that file extension is know to your browser / computer, it will still get sent back as type application/octet-stream.
One word of caution - don't think that putting a .cfm extension on a file will automatically secure it. You need to do the logic for the requests. Think about a TXT file, a CSV, or some other text-based document; that's what ColdFusion pages really are. If you tack a .cfm onto a .csv file, and someone accesses it directly, the CSV file will display on the screen since ColdFusion has no problem executing it. To counter act this vulnerability, you might want to use the ColdFusion CFSetting tag to turn on EnableCFOutputOnly. That way, if someone does access a cfm-secured text file, at least nothing will output unless there are explicit CFOutput tags in the requested file.
Want to use code from this post? Check out the license.
Reader Comments
Ben,
nice solution to a common problem. Thanks!
My alternate suggestion on achieving the same thing is:
1. Setup a virtual directory in IIS
2. Go the the virtual directory properties and under the "main" tab click on the "Configuration" button
3. Add a application extension mapping for ".*" to be processed by the ColdFusion server: "C:\CFusionMX7\runtime\lib\wsconfig\1\jrun.dll"
Now all requests (html, doc, etc.) should be first handed to ColdFusion. This way you can use your Application.cfc to control access.
The pitfalls might be:
1. Not very scalable solution
2. Performance
Might be still worth trying though.
Forgot to mention that is the solution the guy from Blue Dragon failed to mention when giving his presentation on IIS 7. I talked to him afterwards and he agreed that it's possible even though he argued about the performance.
@Boyan,
That's an interesting solution. I am not sure how shared hosting stuff works, but you might not be able to set site-specific IIS directives like that (and I am sure the shared hosting people won't make blanket changes to have common files route through CFM).
But, in a non-shared environment, that could be very cool!
Doh! I'm an idiot. I totally missed the shared hosting environment bit.
Hey, it's Friday :D
I tried implementing this code into my CF7 application. It works to a point...
What happens is that CF parses every file with .cfm extention as you would expect, even if a binary file. This is fine until its finds a random bunch of characters like "<cf564" tries to execute it as cfml and throws an error. This happens in about 1 in every 10 pdf files uploaded for me.
Much to my surprise, it does this parsing before anything else on calling OnRequest or even OnRequestStart, so as far as I can tell I can't intercept it the request to do anything else. By merely calling the method it throws the error.
I am a bit stumped on this one, any suggestions are welcome.
My "fallback" option is just to use unique obscure filenames and serve the pdf files using cfcontent with a different name. That is less than ideal security I guess.
@Regs,
That is strange. I would not have expected the parsing to take place until the file is actually included. Funky!
Another possible solution, depending on the situation, is storing your files as binaries in a database. This has worked for a client recently.