“Tell me! Did you know a hacker could get a user’s files by guessing file names?!”

The bright interrogation lights are shining in your face. You can’t quite make out the person barraging you with questions.

It’s a scenario no one wants to experience: Your files were unprotected, and now someone’s secrets have been exposed. If securing static files for individual users is so simple, why does it always seem to escape us?

Over the years, strategies to secure static files have ranged from feasible (but slow) to downright laughable. Storing the files in the database and treating them like any other secure data seems fair but leads to performance problems as the database has to shuttle copious amounts of information.

One developer once suggested copying the originals and redirecting users to a randomly named temporary file. That’s all well and good, but a determined person can still easily access them. We want our files to sit behind our authentication authorization. Here are the steps I took to ensure my files are secure—one successful, the other… not so much. We’ll start with that attempt.

Attempt one

Create a plug that will serve static files, and add an authentication plug right in front of it. This approach sounds reasonable enough to take it for a spin. For demonstration purposes, we’ll use Plug’s built-in Plug.BasicAuth module. We’ll store our files in priv/static and server them from /files. We can start with a new Phoenix project using mix phx.new static_web.

defmodule StaticWeb.Router do
  use StaticWeb, :router
  import Plug.BasicAuth

  pipeline :static do
    plug :basic_auth, username: "user", password: "pass"

    plug Plug.Static,
      at: "/files",
      from: {:static, "priv/static"},
      gzip: true
  end

  scope "/", StaticWeb do
    pipe_through :static
  end
end

Taking a look at /priv/static, there is a robots.txt file we can use to do our testing. With the router we just updated, we would expect to be able to hit http://localhost:4000/files/robots.txt and get our file with the correct username and password for BasicAuth. Let’s start up our server and give it a try. Run mix phx.server and then visit the URL.

BOOM!

Phoenix.Router.NoRouteError at GET /files/robots.txt
no route found for GET /files/robots.txt (StaticWeb.Router)

What just happened? We thought that this would work, but it appears that we didn’t get to pipe through the static pipeline. Why is that?

Attempt two

If you think of the router as a series of filters every route must pass through, the reason for this error isn’t apparent. The hidden gem here is the Plugs we pipe through aren’t part of this series of filters. The Phoenix router must first match a pathname, and only then will it pass the request through the pipeline we created. Let’s update our code to add a matcher for the path.

defmodule StaticWeb.Router do
  use StaticWeb, :router
  import Plug.BasicAuth


  pipeline :static do
    plug :basic_auth, username: "user", password: "pass"

    plug Plug.Static,
    at: "/files",
    from: {:static, "priv/static"},
    gzip: true
  end

  scope "/", StaticWeb do
    pipe_through :static
    get "/*path", FileNotFoundController, :index
  end
end
defmodule StaticWeb.FileNotFoundController do
  use StaticWeb, :controller
  
  def index(conn, _params) do
    conn
    |> put_status(404)
    |> text("File Not Found")
  end
end

Let’s try that route again. We have our file only if we have correctly authenticated ourselves with our system. We’re part of the way there. What happens if a file doesn’t match any file in our /priv/static directory? Let’s try it. Go to a file that doesn’t exist. /files/not-there.txt should work.

When we navigate there, it falls through to our controller that identifies the error case. In this scenario, we return a simple error, but we could also add some logic to detect common mistakes or alert us to suspicious activity.

With all the code in place, one last problem remains. Try to navigate to /not-real/robots.txt. We’ve put ourselves in a position where any route will match and get into our FileNotFoundController.

That probably isn’t how we want to handle all bad routes—just ones specific to files. We also don’t want to put the static Plug in all the routes. Let’s add a scope to prevent the issue.

defmodule StaticWeb.Router do
  use StaticWeb, :router
  import Plug.BasicAuth

  pipeline :static do
    plug :basic_auth, username: "user", password: "pass"

    plug Plug.Static,
    at: "/files",
    from: {:static, "priv/static"},
    gzip: true
  end

  scope "/files", StaticWeb do
    pipe_through :static
    get "/*path", FileNotFoundController, :index
  end
end

After this last change, navigate to /files/not-there.txt and find that we have a Phoenix.Router.NoRouteError. We expect this when we have a typo in the route and prefer it to every illegal URL coming back with “File Not Found” while in the development environment. We’re also no longer sending every request through Plug.Static.

Adding authorization is another step but can be included as another plug like Plug.BasicAuth. Try it for yourself and share your ideas with us on Twitter.

Amos King is the founder and CEO of Binary Noggin. A leading expert in emerging software languages, Amos is also a frequent speaker at conferences like ElixirConf and Lonestar Elixir and co-host of the popular Elixir Outlaws and This Agile Life podcasts.

Founded in 2007, Binary Noggin is a team of software engineers and architects who serve as a trusted extension of your team, helping your company succeed through collaboration. We forge customizable solutions using Agile methodologies and our mastery of Elixir, Ruby and other open-source technologies. Share your ideas with us on Facebook and Twitter.