Scope Traversal Behavior With Undefined Function Arguments In Lucee CFML 5.3.6.61
Just now, as I was looking at iterating over Structs using CFLoop
in Lucee CFML, I ran into a fun little behavior of the ColdFusion language: Scope traversal will skip-over undefined function arguments and access like-named values in higher-up scopes. At first, this feels like a bug. But, I think it ends-up being consistent with how ColdFusion has always handled scope traversal. That said, as a mental refresher, I thought it would be worth exploring this use-case in Lucee CFML 5.3.6.61.
To see this scope traversal / scope look-up behavior in action, consider the following code in which we are performing Struct-iteration using the .each()
member-method:
<cfscript>
data = [
"a": nullValue(),
"b": "bee",
"c": nullValue()
];
value = "The Spanish Inquisition";
data.each(
( key, value ) => {
// CAUTION: When we reference VALUE here, ColdFusion is going to look for it
// in a cascading number of places. First, the LOCAL scope. Then, the
// ARGUMENTS scope. Then the VARIABLES scope. If VALUE is undefined in the
// lower-level scopes, ColdFusion will continue to look for it higher-up in
// the scope-chain.
echo( key & " : " & ( value ?: "[undefined]" ) & " <br />" );
}
);
</cfscript>
As you can see, two of the keys in the data
Struct reference undefined values. Now, if we iterate over this Struct and attempt to output the key-value pairs, we get the following browser output:
a : The Spanish Inquisition b : bee c : The Spanish Inquisition
Nobody expects the Spanish Inquisition! Instead, you probably expect [undefined]
to show-up in the output. However, the argument name for our iteration value is value
, which is also the name of a variable in the Variables
scope. So, what's actually happening is that when we reference the unscoped variable value
, ColdFusion is doing this:
- Does
value
exist in thelocal
scope? No, move on. - Does
value
exist in thearguments
scope? No, move on. - Does
value
exist in thevariables
scope? Yes, use it.
Because of the scope look-up / cascading behavior in ColdFusion, our undefined value
argument is skipped-over and we end up consuming the value
defined on the variables
scope.
To be fair, the Lucee documentation on Scope usage recommends that you explicitly scope all but the closest scope. In my demo, the closest scope is the local
scope. As such, according to their recommendation, we should explicitly scope our arguments
. Meaning, we should be referencing arguments.value
, not value
. And, if we make that change, we do get the expected output.
That said, adding explicit scopes to variable references is a personal preference. Of course, there is some performance benefit with not having to traverse the scope-chain. But, it all depends on what trade-offs you want to make in your code.
It could easily be argued that the true issue with the code is that I have both a local and global variable with the same name. In my opinion, this is a poor developer-choice as it creates ambiguity in reading of the code. Remove that ambiguity and you likely remove the unexpected behavior.
ASIDE: When I ran into this, I did some Googling and found that there is already a Lucee Developer Ticket that discusses this. It appears that if you enable full-null support in Lucee, the behavior may change.
Scope Cascading From a Security Point-of-View
Scope cascading does open-up an interesting security conversation because the url
scope is towards the top of the cascade. Which means, a malicious actor could theoretically provide a "malicious fallback" value in the URL if they knew the name of an optional argument deep down in the call-stack.
Of course, this would require a "perfect storm" of insights and code constructs. But, it's not outside the realm of possibility. To see what I mean, let's look at a really simple UserService.cfc
component that can create accounts with a given role:
component
output = false
hint = "I provide method for access user accounts."
{
public numeric function createUserAccount(
required string name,
required string email,
required string password,
string role
) {
var dbArguments = {
name: name,
email: email,
password: password,
// NOTE: If the role argument is undefined, we are going to fallback to a
// standard user.
// --
// NOTE: If we provide a default value in the function signature, we would
// not have any issues here.
role: ( role ?: "user" )
};
systemOutput( serializeJson( dbArguments ), true, true );
}
}
In this createUserAccount()
method, the calling context can pass-in an optional role
. And if they don't, we are going to fallback to using "user"
via the Elvis operator.
Now, we can consume this method even when omitting the role
argument:
<cfscript>
userService = new UserService();
userService.createUserAccount(
name = "Ben",
email = "ben@bennadel.com",
password = "ourdeepestfearisnotthatweareinadequate"
);
</cfscript>
If a user runs this page without any malicious intent, we get the following terminal output (formatted for readability):
{ "role": "user", "password": "ourdeepestfearisnotthatweareinadequate", "name": "Ben", "email": "ben@bennadel.com" }
As you can see, the role
was defaulted to "user"
.
But, if a malicious user were to run this page with the following URL:
./url-test.cfm?role=ADMIN
... we would get the following terminal output (formatted for readability):
{ "role": "ADMIN", "password": "ourdeepestfearisnotthatweareinadequate", "name": "Ben", "email": "ben@bennadel.com" }
As you can see, because of the scope cascading behavior in ColdFusion, the malicious user was able to override the role
argument with a URL-based value, "ADMIN"
.
To be clear, a lot of things have to go terribly wrong in order to expose a behavior like this. Critical values like role
shouldn't be defaulted - they should always be explicitly defined in the business logic. And, if the argument
signature had been changed to have a default value:
string role = "user"
... then we would have gotten the expected fallback, not the malicious value.
My point here isn't to strike fear into anyone's heart; or to try and convince everyone that they should immediately go explicitly-scope every reference (I don't do that). But, it's good to have this scope traversal behavior in the back of your mind so that you can pause and evaluate your coding decisions.
ColdFusion is a really dynamic language. This is part of what makes it such a joy to work with; and, really easy to get up and running. But, that dynamic behavior comes with a cost: both from a performance stand-point and from a mental-model stand-point. The point here is just to always be building-up a better understanding of how the runtime works. And, when something surprises you, stop and think about what it's doing mechanically; and, how you can bring that better-understanding with you into future decisions about application architecture.
Want to use code from this post? Check out the license.
Reader Comments
I guess I'm a little confused. I always thought member functions setup a closure. In fact, looking at the documentation for array.each() and struct.each() they both seem to back this up...
As such, I'd expect the key/value setup by the closure to both be locally scoped to their closure and that the closure wouldn't be able to access scopes outside of itself unless you passed them in explicitly. 🤔
@Chris,
Actually, I believe a closure is designed for just precisely that: to provide you with access to the scope of its outer function.
@Chris, @Andrew,
Exactly - the closure is just about bindings to the lexical scope - ie, the scope in which the closure was defined (not executed). The issue here is a closure doesn't remove the scope-traversal that the ColdFusion runtime does when evaluating variables. All it does is adjust which scopes are traversed.
@Ben, @Andrew,
Thanks for the reminder. My mental model for closures needed this refresher.
@Chris,
What would be great if ColdFusion just had first-class support for
null
-- then a lot of these issues go away (well, at least this one) :D I know Lucee CFML has a setting that you can turn on, but I have not tried it yet - I do think that would change how this works (but can't confirm).@Ben,
I'd waited so long for full NULL support in CFML but even though Lucee has now had it for some time now, I've been reluctant to implement it out of fear!!
For years I've coded around and against these CFML oddities caused by a lack of NULL support, so that now I fear a whole new batch of oddities will crop up on these VERY large code bases once implemented. Especially with all of the integration with 3rd party frameworks and module code.