Skip to main content
Ben Nadel at the New York ColdFusion User Group (May. 2008) with: Clark Valberg and Michael Dinowitz
Ben Nadel at the New York ColdFusion User Group (May. 2008) with: Clark Valberg Michael Dinowitz

Securing ColdFusion Scheduled Tasks In A Docker Container Using Lucee CFML 5.3.8.206

By
Published in Comments (4)

As I mentioned in my previous post on managing shared secret token rotation across systems, I've been cleaning up some really old code, moving hard-coded passwords into environment variables. One place in which we had a hard-coded password was in our ColdFusion Scheduled Task ingress. As I was updating this code, it occurred to me that the Docker-based reality in which many of us now live has implications on the way in which we can secure our ColdFusion scheduled tasks. As such, I wanted to put together a small demo exploring the various ways in which we can secure a ColdFusion scheduled task running in a Dockerized container using Lucee CFML 5.3.8.206.

View this code in my Securing ColdFusion Scheduled Tasks project on GitHub.

CAUTION: I am not a security expert. And, I only have an entry-level understanding of Docker and how containers work in general. Please use this post as an exploratory conversation - not as a point of best practice.

A ColdFusion scheduled task is essentially a URL within your ColdFusion application that is requested - via an HTTP call - on a recurring basis. The invocation of that URL is managed by the ColdFusion scheduler; and, can be defined manually within the ColdFusion administrator, programmatically within the ColdFUsion code, or as part of the server's configuration files (depending on your setup).

ColdFusion, which is running on top of a Java (JEE) servlet like Tomcat (of which I know next-to-nothing), typically binds to a high port, like 8500 or 8888. There is generally a downstream "web server" that sits in front of ColdFusion and acts as a reverse proxy that maps the common ports - 80 (insecure) and 443 (secure) - to the internal ColdFusion port. The web server can also manage features like static-asset delivery and SSL termination.

For this demo, I'm going to use one of the core Lucee CFML Docker images that embeds nginx as the "web server" / reverse proxy. In this container, Lucee CFML runs on port 8888; but, as we'll see in a bit, our docker-compose.yml only exposes port 80 (to the public) on the host machine.

And now that we have a better understanding of the machinery that is running under the hood, we can talk about how we might secure our ColdFusion scheduled task. In this demo, I'm exploring three options:

  • Locking the ColdFusion scheduled task down to the internal IP address (127.0.0.1).

  • Locking the ColdFusion scheduled task down to the internal port (8888).

  • Locking the ColdFusion scheduled task down with a shared secret.

To kick this off, let's look at the docker-compose.yml file that wires our Docker containers together:

version: "2.4"

services:

  lucee:
    container_name: "lucee"
    build: "../lucee"
    volumes:
      - "../lucee/app:/var/www"
    ports:
      # Defined as "HOST:CONTAINER" . In this demo container, we have NGINX sitting as a
      # reverse proxy in front of TOMCAT. And, we're only exposing the non-secure HTTP
      # port 80. Tomcat is listening on port 8888, which is not exposed by the container.
      # Meaning, no PUBLIC INGRESS into this container can hit Tomcat directly.
      - "80:80"
    environment:
      - "TASK_PASSWORD=ColdFusionIsLife!"

There are two points of note in this compose file:

  • First, we're only exposing port 80. This means that while our Lucee CFML server is running on port 8888 internally, this internal port cannot be accessed by the outside world! As such, any request that invokes a ColdFusion template on port 8888 (cgi.server_port) must be an internal request.

  • Second, we're injecting an environment variable, TASK_PASSWORD, that we'll use in both our ColdFusion scheduled task configuration and in the validation of our scheduled task execution.

Here is the Application.cfc ColdFusion application component that bootstraps this demo. It is where I am configuring the scheduled task using the CFSchedule tag. You'll want to note three things:

  • It's an HTTP request that targets IP 127.0.0.1.

  • It's an HTTP request that targets port 8888.

  • It includes the aforementioned ENV password in the task URL.

component
	output = false
	hint = "I define the application settings and event handlers."
	{

	// Define application settings.
	this.name = "SecureScheduledTaskDemo";
	this.applicationTimeout = createTimeSpan( 1, 0, 0, 0 );
	this.sessionManagement = false;
	this.setClientCookies = false;

	// ---
	// LIFE-CYCLE METHODS.
	// ---

	/**
	* I get called once when the application is being started. This method call is
	* single-threaded across all requests while the application is being bootstrapped.
	*/
	public void function onApplicationStart() {

		application.scheduledTaskPassword = env( "TASK_PASSWORD" );

		// NOTE: We are configuring the "task.cfm" script to be requested using the
		// localhost domain, "127.0.0.1", and an internal port, "8888". Since port 8888
		// is NOT EXPOSED by the container, we know that a PUBLIC request will never use
		// this port.
		// --
		// NOTE: There is a bug in recent version of Lucee CFML where the "Authorization"
		// header is not being sent-through, even if the username/password attributes are
		// defined. See: https://luceeserver.atlassian.net/browse/LDEV-2925 . As such,
		// I'm using the URL to provide the password.
		schedule
			action = "update"
			task = "RunTask"
			operation = "HTTPRequest"
			url = "http://127.0.0.1:8888/task.cfm?password=#encodeForUrl( application.scheduledTaskPassword )#"
			startDate = "2021-01-01"
			startTime = "00:00 AM"
			interval = 30 // Every 30-seconds.
		;

	}


	/**
	* I get called once at the start of every request into the ColdFusion application.
	*/
	public void function onRequestStart() {

		if ( url.keyExists( "init" ) ) {

			applicationStop();
			location( url = "/index.cfm", addToken = false );

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I return the given environment variable value; or, the fallback if the variable is
	* either UNDEFINED or EMPTY.
	*/
	private string function env(
		required string name,
		string fallbackValue = ""
		) {

		// In Lucee CFML, we can access the environment variables directly from the
		// SERVER SCOPE.
		var value = ( server.system.environment[ name ] ?: "" );

		// For the sake of the demo, we're treating an EMPTY value and a NON-EXISTENT
		// value as the same thing, using the given value only if it is populated.
		return( value.len() ? value : fallbackValue );

	}

}

The localhost IP address - 127.0.0.1 - known as the "loopback" address, is a special address the represents "this server". And, it means the same thing on every single computer. Which means, the localhost IP address can never refer to "another" server. As such, any request that is made to IP 127.0.0.1, by definition, must have been initiated by the same machine. And a request to 127.0.0.1 will never leave the network in which it was initiated.

What this means is that if our ColdFusion scheduled task is configured to hit http://127.0.0.1:8888, it's doing so on an internal-only IP address using a port that is not exposed to the public. Which means, in our task.cfm template, we can lock the execution down by examining the IP address, the port, and the password:

<cfscript>

	param name="url.password" type="string" default="";

	// Since the ColdFusion scheduled task is configured as an HTTP request from THIS
	// SERVER to THIS SERVER, we are making the request against the localhost address.
	// As such, we can deny any requests not coming from localhost (ie, any public
	// request to this URL).
	if ( cgi.remote_addr != "127.0.0.1" ) {

		send404( "Incorrect IP address used [#cgi.remote_addr#]." );
		abort; // Not needed, just here for emphasis.

	}

	// In this demo container, we have NGINX (port 80) sitting in front of TOMCAT (port
	// 8888) as a reverse proxy. Furthermore, TOMCAT's ports are NOT EXPOSED PUBLICLY. As
	// such, we can deny any request not coming over port 8888.
	if ( cgi.server_port != "8888" ) {

		send404( "Incorrect server port used [#cgi.server_port#]." );
		abort; // Not needed, just here for emphasis.

	}

	// The ColdFusion scheduled task is configured to include a password in the URL. As
	// such, we can deny any request that doesn't include the expected password.
	if ( compare( url.password, application.scheduledTaskPassword ) ) {

		send404( "Incorrect task password used [#url.password#]." );
		abort; // Not needed, just here for emphasis.

	}

	// There's no actual logic in this scheduled task - we're just exploring security
	// options. Let's just log a message so that we know it executed.
	systemOutput( "Task.cfm executed at #timeFormat( now(), 'HH:mm:ss' )#", true );
	systemOutput( "  - IP: #cgi.remote_addr#", true );
	systemOutput( "  - Port: #cgi.server_port#", true );
	systemOutput( "  - Password: #url.password#", true );
	systemOutput( "", true );

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	/**
	* I log the given message and then return a "404 Not Found" in the HTTP response.
	* This call halts all further processing of the request.
	*/
	public void function send404( required string errorMessage ) {

		systemOutput( "Unauthorized Request: #errorMessage#", true, true );
		systemOutput( "", true );

		header
			statusCode = "404"
			statusText = "Not Found"
		;
		// CAUTION: Halts all further processing of the current request.
		content
			type = "text/html"
			variable = charsetDecode( "Not Found", "UTF-8" )
		;

	}

</cfscript>

Once our ColdFusion application boots-up, if we look at the logs, we'll see that our scheduled task is successfully executing every 30-seconds:

lucee    | Task.cfm executed at 11:13:00
lucee    |   - IP: 127.0.0.1
lucee    |   - Port: 8888
lucee    |   - Password: ColdFusionIsLife!
lucee    | 
lucee    | Task.cfm executed at 11:13:30
lucee    |   - IP: 127.0.0.1
lucee    |   - Port: 8888
lucee    |   - Password: ColdFusionIsLife!
lucee    | 
lucee    | Task.cfm executed at 11:14:00
lucee    |   - IP: 127.0.0.1
lucee    |   - Port: 8888
lucee    |   - Password: ColdFusionIsLife!

However, if we try to hit the task.cfm manually in the browser (over the only publicly-exposed port, 80), we get a 404 Not Found response. And, in the server logs, we see:

lucee    | 
lucee    | Unauthorized Request: Incorrect IP address used [172.100.0.1].
lucee    | 

And, if I comment out the first security check and try again, we see this in the logs:

lucee    | 
lucee    | Unauthorized Request: Incorrect server port used [80].
lucee    | 

And finally, if I comment out the second security check and try again, we see this in the logs:

lucee    | 
lucee    | Unauthorized Request: Incorrect task password used [].
lucee    | 

As you can see, each of the three layers of security checks was capable of blocking an attempted external invocation of the ColdFusion scheduled task.

Again, I'm not a security expert! And, I'm basically a Docker n00b. So, please take this post in that light. But, I believe that this approach should be more than sufficient for locking down a ColdFusion scheduled task when running inside a Docker container.

And, if you have any better suggestions, please let me know in the comments!

Want to use code from this post? Check out the license.

Reader Comments

31 Comments

How's that environment variable stored? If it's just being read from a text-based config file (possibly stored in source control?) then you're really not solving the problem you have with hard coding the password in the application. Is the storage mechanism encrypted?

Also, is your transport encrypted? You've got Lucee running on port 8888, but I've been out of the CF game long enough that I don't know if that will be running HTTP or HTTPS.

Proper secrets management is a pain. It's just less of a pain than improper secrets management can be. :)

15,848 Comments

@Matt,

Good questions. In our case (at work), we lean on the "Platform" to manage the secrets. And, to be honest, I don't fully understand how that platform works (it's Kubernetes managing Docker containers). I just know that there's a dashboard for me to create (and then read again) new ENV values.

As far as the HTTP vs. HTTPS issue, I think we OK in that we're using the localhost address, so the network activity should never leave the system. At least, that's as far as I understand it. Though, some of this stuff is outside my purview and is managed / built by the Platform team.

If I were do something small and personal, I would likely just have a config.json file, or a .env file that I hard-code on the production server, but outside of the source code. Then, in my Application.cfc, I would read-in that config/env file outside the webroot. It's not elegant, but it's easy.

31 Comments

@Ben,

Problem is, something that is unencrypted can be easily read. Something that's internal-only reduces your attack surface but it's worth pointing out that two of the three biggest exploits I've had to help clean up were inside jobs.

A config file with secrets in it gets checked in to source control and now those "secrets" are freely readable by anyone with access to source control. That file gets pulled in to deployment pipelines, possibly increasing the number of people with access to that unencrypted information. Then the file sits on a server somewhere, further increasing the number of people who can read it. Is that secret login credentials to your database? Then it's likely (and common IRL) that most of your IT staff has login access to your database.

Secrets management is hard.

15,848 Comments

@Matt,

100% and people are always the weakest link 🤪 But, to be clear, I wasn't intending the aforementioned config files to be checked into source-control. What I would normally do is have files like:

  • config.template.json
  • config.json

... where the .template. version is checked into git so that people can see which structures are required. But, the config.json version would be added to the .gitignore so that it never gets committed to the code. Then, it can be generated locally in dev and also manually in prod (by a limited set of people with security access); but, never hits source-control.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel