Austin Elixir

Routing Securely with Phoenix Framework

2015-09-02

Luke Imhoff

luke_imhoff@rapid7.com Kronic.Deth@gmail.com
@limhoff-r7 @KronicDeth
@KronicDeth

This Presentation

Live Stream/Recording
https://youtu.be/lrlHYHKVWiI
Slides
Viewable
https://kronicdeth.github.io/routing-securely-with-phoenix-framework
Source
https://github.com/KronicDeth/routing-securely-with-phoenix-framework/tree/gh-pages
Project Source
https://github.com/KronicDeth/routing-securely-with-phoenix-framework/tree/master

Outline

  1. Installation
  2. New Project
  3. Configuration
  4. TLS
  5. Authentication
  6. CSRF
  7. Channels
  8. XSS
  9. Compromise Recovery

Installation

Node

Source Linux Mac Windows
nodejs.org tar.gz pkg msi
homebrew brew install node
apt-get sudo apt-get install nodejs-legacy

Installing Mix Archives


mix local.hex
mix archive.install https://github.com/phoenixframework/phoenix/releases/download/v0.17.0/phoenix_new-0.17.0.ez
                        

New Project

  1. mix phoenix.new Options
  2. mix phoenix.new
  3. Fetch and Install Dependencies

  4. Run

mix phoenix.new Options

Option Description Format Example
--app APP The name of the OTP application Atom routing_securely_with_phoenix_framework
--database DATABASE Specify the database adapter for ecto
  • mssql
  • mysql
  • postgres (default)
  • sqlite
--module MODULE The name of the base module in the generated skeleton Alias RoutingSecurelyWithPhoenixFramework
--no-ecto Do not generate Ecto files for the model layer
--no-brunch Do not generate brunch files for static asset building

mix phoenix.new

mix phoenix.new routing_securely_with_phoenix_framework

Fetch and Install Dependencies

Fetch and install dependencies to install javascript libraries with npm install and install elixir dependencies with mix deps.get

Fetching and Install Dependencies for Clone

Error Solution
Error telling user to run mix deps.get when running mix phoenix.server mix deps.get
Error telling user to run npm install for brunch when running mix phoenix.server npm install

Run your application

cd routing_securely_with_phoenix; mix ecto.create; mix phoenix.server

Configuration

Default Configuration

Environment Module key File Source Controlled?
All Endpoint secret_key_base config/config.exs Yes
dev Repo password config/dev.exs Yes
prod Endpoint secret_key_base config/prod.secret.exs No
prod Repo password config/prod.secret.exs No
test Repo password config/test.exs Yes

Remove shared secret_key_base

Remove secret_key_base from config/config.exs

Secret configuration for each environment

Remove secret configuration import from config/prod.exs
Remove improt_config "prod.secret.exs" in config/prod.exs
Import secret configuration for each environment in config/config.exs
import_config "#{Mix.env}.secret.exs" in config/config.exs
Ignore all secret configurations in git
git ignore config/*.secret.exs instead of config/prod.secret.exs

Generating new secret_key_base


iex(1)> length = 64
64
iex(2)> :crypto.strong_rand_bytes(length) |> Base.encode64 |> binary_part(0, length)
                        

Generating database password

  • Use a password manager
  • Make the password long (> 16 characters)
  • Replace password every 90 days

Using KeePass to generate a 64 character password with lowe letter, upper letters, numbers, and special characters that expires in 3 months

Dev Secrets

Remove Repo configuration from config/dev.exs
Remove config APP_NAME, MODULE_NAME.Repo from config/dev.exs
Create config/dev.secret.exs

use Mix.Config

# In this file, we keep development configuration that
# you likely want to automate and keep it away from
# your version control system.
config :routing_securely_with_phoenix_framework, RoutingSecurelyWithPhoenixFramework.Endpoint,
  secret_key_base: "SECRET_KEY_BASE"

# Configure your database
config :routing_securely_with_phoenix_framework, RoutingSecurelyWithPhoenixFramework.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "routing_securely_with_phoenix_framework_dev",
  host: "127.0.0.1",
  password: "PASSWORD",
  size: 20, # The amount of database connections in the pool
  username: "routing_securely_with_phoenix_framework_dev"
                            

Test Secrets

Remove Repo configuration from config/test.exs
Remove config APP_NAME, MODULE_NAME.Repo from config/test.exs
Create config/test.secret.exs

use Mix.Config

# In this file, we keep development configuration that
# you likely want to automate and keep it away from
# your version control system.
config :routing_securely_with_phoenix_framework, RoutingSecurelyWithPhoenixFramework.Endpoint,
  secret_key_base: "SECRET_KEY_BASE"

# Configure your database
config :routing_securely_with_phoenix_framework, RoutingSecurelyWithPhoenixFramework.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "routing_securely_with_phoenix_framework_test",
  password: "PASSWORD",
  pool: Ecto.Adapters.SQL.Sandbox,
  username: "routing_securely_with_phoenix_framework_test"
                            

Create PostgreSQL Users

Termianl pgAdmin
1 createuser --createdb --encrypted --no-createrole --no-superuser --pwprompt USERNAME Start pgAdmin3
2 Enter password Connect to server
3 Enter password (again) Right-click Login Roles
4 Click New Login Role
5 Enter username as Role Name
6 Change to Definition Tab
7 Enter password
8 Enter password (again)
9 Change to Role Privileges Tab
10 Click "Can create database"
11 Click OK

Create the database

mix ecto.create for new project
Top lines of mix ecto.create in new project compile fs application
Bottom lines of mix.ecto.create in new project generate app and finally create the database
mix ecto.create in built project
mix ecto.create in a pre-built project does not compile and only creates the database

TLS

Overview

  • Transport Layer Security
  • Successor to SSL (Secure Socket Layer) 3.0
  • TLS 1.0 was defined in 1999

Certificate Authority Key

openssl genrsa -des3 -out certificate-authority.key 4096
Option Argument Description
genrsa Generate RSA key
-des3 Password protect the private key
-out certificate-authority.key Private Key file
4096 Number of bits in RSA key

Self-Signing Certificate Authority

openssl req -new -x509 -days 365 -key certificate-authority.key -out certificate-authority.crt -sha256
Option Argument Description
req X.509 Certificate Signing Request (CSR) Management
-new New Certificate Request
-x509 Self-signed certificate
-days 365 Number of days certificate is valid
-key certificate-authority.key Input file name for private key used for signing
-out certificate-authority.crt Output file for self-signed certificate
-sha256 Sign using SHA256 instead of SHA1

Self-Signing Certificate Authority Distinguished Name

Entering C,ST,L,O,CN, and emailAddress for distinguished name

Viewing your self-signed certificate

openssl x509 -in certificate-authority.crt -noout -text

Data stored in certificate-authority.pem

Server Key

openssl genrsa -out server-${MIX_ENV}.key 4096

Server Certificate Signing Request

openssl req -new -key server-${MIX_ENV}.key -out server-${MIX_ENV}.csr

Server Certificate Signing Request Distinguished Name

Entering C,ST,L,O,CN, and emailAddress for distinguished name.  CN must match DNS name (dev.phoenix.localhost) of MIX_ENV server

Signing Server Certificate Request

openssl x509 ‑CA certificate‑authority.crt ‑CAcreateserial ‑CAkey certificate‑authority.key ‑days 90 ‑in server‑${MIX_ENV}.csr ‑out server‑${MIX_ENV}.crt ‑req ‑sha256
Option Argument Description
x509 Certificate display and signing utility
‑CA certificate‑authority.crt The Certificate Authority certificate
‑CAcreateserial Create file for keeping track of CA issued certificate serial numbers if it does not exist and assign this certificate the next serial number.
‑CAkey certificate‑authority.key Certificate Authority private Key for signing request
‑days 90 Days the server certificate is valid
‑in server‑${MIX_ENV}.csr Request to be signed
‑out server‑${MIX_ENV}.crt Signed certificate output file
‑req Sign a certificate request
‑sha256 Sign with SHA256 instead of SHA1

Trust Self-Signed Certificate Authority

OSX

  1. Open Keychain Access
  2. Click Plus
  3. Select certificate-authority.crt
  4. Highlight the certificate
  5. Click i
  6. Expand Trust
  7. Change "When using this certificate" to Always Trust
  8. Close i Dialog
  9. Enter password to save changes

Fully Qualified Domain Setup

  1. Open /etc/hosts
  2. Add the following lines

    
    127.0.0.1 dev.routing-securely-with-phoenix-framework.localhost
    127.0.0.1 prod.routing-securely-with-phoenix-framework.localhost
    127.0.0.1 test.routing-securely-with-phoenix-framework.localhost
                                    
  3. Restart Browsers

TLS Configuration

config/config.exs TLS changes

TLS dev Configuration

config/dev.exs TLS changes

TLS prod Configuration

config/prod.exs TLS changes

TLS test Configuration

config/test.exs TLS changes

Testing TLS connection

  1. Start Phoenix:
    mix phoenix.server
    mix phoenix.server print cowboy URL
  2. Open HTTP URL printed by Cowboy
  3. Verify redirected to HTTPS URL printed by Cowboy
  4. Verify connection is private
    Padlock is green and connection is private

Authentication

Authentication Providers

Don't store passwords if you don't have

User model

mix phoenix.gen.model User users name password_hash
Argument Description
User Module name
users Table name
name Column name
password_hash Column name

User Migration


defmodule RoutingSecurelyWithPhoenixFramework.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string, null: false
      add :password_hash, :string, null: false

      timestamps
    end

    create unique_index(:users, [:name])
  end
end
                        

User Schema


defmodule RoutingSecurelyWithPhoenixFramework.User do
  use RoutingSecurelyWithPhoenixFramework.Web, :model

  schema "users" do
    field :name, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true
    field :password_hash, :string

    timestamps
  end
end
                        

User Changeset


defmodule RoutingSecurelyWithPhoenixFramework.User do
  @minimum_password_length 16
  @optional_fields ~w()
  @required_fields ~w(name password password_confirmation)

  @doc """
  Creates a changeset based on the `model` and `params`.

  If no params are provided, an invalid changeset is returned
  with no validation performed.
  """
  def changeset(model, params \\ :empty) do
    model
    |> cast(params, @required_fields, @optional_fields)
    |> validate_length(:password, min: @minimum_password_length)
    |> validate_length(:password_confirmation, min: @minimum_password_length)
    |> validate_confirmation(:password)
    |> unique_constraint(:name)
  end
end
                        

User password hashing


defmodule RoutingSecurelyWithPhoenixFramework.User do
  @doc """
  Generates a password for the user changeset and stores it to the changeset as password_hash.
  """
  def generate_password(changeset) do
    put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(changeset.params["password"]))
  end
end
                        

User Registration Routes


defmodule RoutingSecurelyWithPhoenixFramework.Router do
  use RoutingSecurelyWithPhoenixFramework.Web, :router

  scope "/", RoutingSecurelyWithPhoenixFramework do
    pipe_through :browser # Use the default browser stack

    get "/registration", RegistrationController, :new
    post "/registration", RegistrationController, :create
  end
end
                        

User Registration Controller


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationController do
  use RoutingSecurelyWithPhoenixFramework.Web, :controller

  alias RoutingSecurelyWithPhoenixFramework.User

  plug :scrub_params, "user" when action in [:create]
end
                        

User Registration Controller new


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationController do
  def new(conn, _params) do
    changeset = User.changeset(%User{})
    render conn, changeset: changeset
  end
end
                        

User View


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationView do
  use RoutingSecurelyWithPhoenixFramework.Web, :view
end
                        

User new Template


<h3>Registration</h3>
<%= form_for @changeset, registration_path(@conn, :create), fn f -> %>
<%= if f.errors != [] do %>
<div class="alert alert-danger">
    <p>Oops, something went wrong! Please check the errors below:</p>
    <ul>
        <%= for {attr, message} <- f.errors do %>
        <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
    </ul>
</div>
<% end %>

<div class="form-group">
    <label>Name</label>
    <%= text_input f, :name, class: "form-control" %>
</div>

<div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
</div>

<div class="form-group">
    <label>Password Confirmation</label>
    <%= password_input f, :password_confirmation, class: "form-control" %>
</div>

<div class="form-group">
    <%= submit "Register", class: "btn btn-primary" %>
    <%= link("Login", to: session_path(@conn, :new), class: "btn btn-success pull-right") %>
</div>
<% end %>
                        

User Registration Controller create


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationController do
  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)

    if changeset.valid? do
      case changeset |> User.generate_password |> Repo.insert do
        {:ok, user} ->
          conn
          |> put_flash(:info, "Successfully registered and logged in")
          |> put_session(:current_user_id, user_id)
          |> redirect(to: page_path(conn, :index))
        {:error, changeset} ->
          render conn, "new.html", changeset: changeset
      end
    else
      render conn, "new.html", changeset: changeset
    end
  end
end
                        

Page Routes


defmodule RoutingSecurelyWithPhoenixFramework.Router do
  scope "/", RoutingSecurelyWithPhoenixFramework do
    get "/pages", PageController, :index
  end
end
                        

Page Controller Authentication


defmodule RoutingSecurelyWithPhoenixFramework.PageController do
  plug RoutingSecurelyWithPhoenixFramework.Plug.Authenticate
end
                        

Authenticate Plug


defmodule RoutingSecurelyWithPhoenixFramework.Plug.Authenticate do
  import RoutingSecurelyWithPhoenixFramework.Router.Helpers

  alias RoutingSecurelyWithPhoenixFramework.Repo
  alias RoutingSecurelyWithPhoenixFramework.User

  def init(opts) do
    opts
  end

  def call(conn, _opts) do
    assign_current_user(conn)
  end

  defp assign_current_user(conn = %Plug.Conn{}) do
    assign_current_user(conn, Plug.Conn.get_session(conn, :current_user_id))
  end
  defp assign_current_user(conn, id) when is_integer(id) do
    assign_current_user(conn, Repo.get(User, id))
  end
  defp assign_current_user(conn, user = %User{}) do
    Plug.Conn.assign(conn, :current_user, user)
  end
  defp assign_current_user(conn, _), do: redirect_to_sign_in(conn)

  defp redirect_to_sign_in(conn) do
    conn
    |> Phoenix.Controller.put_flash(
         :error,
         'You need to be signed in to view this page'
       )
    |> Phoenix.Controller.redirect(to: session_path(conn, :new))
    |> Plug.Conn.halt
  end
end
                        

Session Routes


defmodule RoutingSecurelyWithPhoenixFramework.Router do
  scope "/", RoutingSecurelyWithPhoenixFramework do
    get "/", SessionController, :new
    post "/login", SessionController, :create
    get "/logout", SessionController, :delete
  end
end
                        

Session Controller


defmodule RoutingSecurelyWithPhoenixFramework.SessionController do
  use RoutingSecurelyWithPhoenixFramework.Web, :controller

  alias RoutingSecurelyWithPhoenixFramework.User

  plug :scrub_params, "user" when action in [:create]
end
                          

Session Controller new


defmodule RoutingSecurelyWithPhoenixFramework.SessionController do
  def new(conn, _params) do
    render conn, changeset: User.changeset(%User{})
  end
end
                        

Session View


defmodule RoutingSecurelyWithPhoenixFramework.SessionView do
  use RoutingSecurelyWithPhoenixFramework.Web, :view
end
                        

Session new Template


<h3>Login</h3>
<%= form_for @changeset, session_path(@conn, :create), fn f -> %>
<%= if f.errors != [] do %>
<div class="alert alert-danger">
    <p>Oops, something went wrong! Please check the errors below:</p>
    <ul>
        <%= for {attr, message} <- f.errors do %>
        <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
    </ul>
</div>
<% end %>

<div class="form-group">
    <label>Name</label>
    <%= text_input f, :name, class: "form-control" %>
</div>

<div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
</div>

<div class="form-group">
    <%= submit "Login", class: "btn btn-primary" %>
    <%= link("Sign Up", to: registration_path(@conn, :new), class: "btn btn-success pull-right") %>
</div>
<% end %>                            
                        

Session Controller create


defmodule RoutingSecurelyWithPhoenixFramework.SessionController do
  def create(conn, %{"user" => user_params}) do
    if is_nil(user_params["name"]) do
      nil
    else
      Repo.get_by(User, name: user_params["name"])
    end
    |> sign_in(user_params["password"], conn)
  end
  # Private Functions

  @sign_in_error "Name or password are incorrect."

  defp sign_in(user, _password, conn) when is_nil(user) do
    conn
    |> put_flash(:error, @sign_in_error)
    |> render "new.html", changeset: User.changeset(%User{})
  end

  defp sign_in(user, password, conn) when is_map(user) do
    if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
      conn
      |> put_session(:current_user_id, user.id)
      |> put_flash(:info, "You are now signed in.")
      |> redirect(to: page_path(conn, :index))
    else
      conn
      |> put_flash(:error, @sign_in_error)
      |> render "new.html", changeset: User.changeset(%User{})
    end
  end
end
                        

Session Status


<!DOCTYPE html>
<html lang="en">
  <body>
    <nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
      <div class="container">
       <div id="navbar" class="navbar-collapse collapse">
          <div class="navbar-form navbar-right">
          <%= case @conn.assigns[:current_user] do %>
          <%    nil -> %>
            <%= link "Login", class: "btn btn-success", role: "button", to: session_path(@conn, :new) %>
          <%    current_user -> %>
            <span class="label label-info">
              <%= current_user.name %>
            </span>
            <%= link "Logout", class: "btn btn-danger", role: "button", to: session_path(@conn, :delete) %>
          <% end %>
          </div>
        </div><!--/.navbar-collapse -->
      </div>
    </nav>
  </body>
</html>
                        

Session Controller delete


defmodule RoutingSecurelyWithPhoenixFramework.SessionController do
  def delete(conn, _) do
    delete_session(conn, :current_user_id)
    |> put_flash(:info, "You have been logged out")
    |> redirect(to: session_path(conn, :new))
  end
end
                        

CSRF

CSRF

Cross-Site Request Forgery

  1. User is logged into goodsite.com
  2. User visits badsite.com
  3. badsite.com contains a link to goodsite.com/widgets/sell
  4. User's browser fetches link to goodsite.com using cookie from goodsite.com
  5. badsite.com has just sold in User's account on goodsite.com to attacker

False CSRF Protections

  • Secret Cookie
  • Not accepting GET requests
  • Multi-Step Transactions

CSRF Protections

  • GET requests MUST NOT change state
  • No XSS vulnerabilities
  • Double submit cookie in requests that change state

CSRF Token in Forms


<h3>Registration</h3>
<%= form_for @changeset, registration_path(@conn, :create), fn f -> %>                            
                        

<form accept-charset="UTF-8" action="/registration" method="post">
    <input name="_csrf_token" type="hidden" value="e3JoLCxfDyBXFHYaNVgjOwEeQx4uNgAA000tv4wt1p1bv4wOMVtHiQ==">
                        

CSRF Token Verification

_csrf_token (param) == _csrf_token (session)

Same Origin Policy

RFC 6454

  • Protocol
  • Host
  • Port

Channels

Overview

  • Bidirectional communications from clients
  • Transported over Websockets or Long Polling
  • Browser, Android, and iOS clients
  • Distributed over PubSub layer between Phoenix nodes

Pages Template


<div id="messages" class="container"></div>
<div id="footer">
  <div class="container">
    <div class="row">
      <div class="col-sm-12">
        <label for="message-input">Your Message:</label>
        <textarea id="message-input" class="form-control">
        </textarea>
      </div>
    </div>
  </div>
</div>
                        

jQuery

package.json

{
  "dependencies": {
    "jquery": "~2.1"
  }
}
                            
brunch-config.js

exports.config = {
  plugins: {
    babel: {
      // Do not use ES6 compiler in vendor code
      ignore: [/web\/static\/vendor/, /node_modules\/jquery/]
    }
  },
};
                                

socket.js


import {Socket} from "deps/phoenix/web/static/js/phoenix"
import $ from "jquery"

let socket = new Socket("/socket")
let socketToken = $("meta[name='socket_token']").attr("content")

if (socketToken !== undefined) {
  socket.connect({token: socketToken})

  // Now that you are connected, you can join the lobby
  let channel = socket.channel("rooms:lobby", {})

  let messageInput = $("#message-input")

  messageInput.on("keypress", event => {
    if (event.keyCode === 13) {
      channel.push("new_message", {body: messageInput.val()})
      messageInput.val("")
    }
  })

  let messagesContainer = $("#messages")

  channel.on("new_message", payload => {
    messagesContainer.append(
      `

${Date()} ${payload.user.name} ${payload.body}

` ) }) channel.join() } export default socket

socket_token meta tag


<!DOCTYPE html>
<html lang="en">
  <head>
  <%= case @conn.assigns[:current_user] do %>
  <%    nil -> %>
  <%    current_user -> %>
    <%= tag :meta,
            content: Phoenix.Token.sign(@conn, "user", {current_user.id, current_user.socket_token}),
            name: "socket_token" %>
  <% end %>
  </head>
</html>
                        

Adding socket_token to users


defmodule RoutingSecurelyWithPhoenixFramework.Repo.Migrations.AddChannelTokenToUser do
  use Ecto.Migration

  def down do
    alter table(:users) do
      remove :socket_token
    end
  end

  def up do
    alter table(:users) do
      # add socket_token as null because it needs to be populated first
      add :socket_token, :string, null: true
    end

    # populate with secure random value using same algorithm as `mix phoenix.gen.secret`
    execute "CREATE EXTENSION IF NOT EXISTS pgcrypto"
    execute "UPDATE users SET socket_token = encode(gen_random_bytes(64), 'base64') WHERE socket_token IS NULL"

    alter table(:users) do
      modify :socket_token, :string, null: false
    end
  end
end
                        

Adding socket_token to User schema


defmodule RoutingSecurelyWithPhoenixFramework.User do
  schema "users" do
    field :socket_token, :string
  end
end
                        

Generating secrets on registration


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationController do
  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)

    if changeset.valid? do
      full_changeset = User.full_changeset(changeset)

      case Repo.insert(full_changeset) do
        {:ok, user} ->
          conn
          |> put_flash(:info, "Successfully registered and logged in")
          |> put_session(:current_user_id, user.id)
          |> redirect(to: page_path(conn, :index))
        {:error, changeset} ->
          render conn, "new.html", changeset: changeset
      end
    else
      render conn, "new.html", changeset: changeset
    end
  end
end
                        

User.full_changeset/1


defmodule RoutingSecurelyWithPhoenixFramework.User do
  @socket_token_length 64

  @doc """
  Fills `changeset` with generated columns.
  """
  def full_changeset(changeset) do
    changeset
    |> generate_password
    |> generate_socket_token
  end

  @doc """
  Generates a channel token for the user changeset.
  """
  def generate_socket_token(changeset) do
    put_change(changeset, :socket_token, generate_secret(@socket_token_length))
  end

  # Private Functions

  @doc """
  Generates a random secret string of the given `length`
  """
  defp generate_secret(length) do
    :crypto.strong_rand_bytes(length) |> Base.encode64 |> binary_part(0, length)
  end
end
                        

UserSocket.connect/2


defmodule RoutingSecurelyWithPhoenixFramework.UserSocket do
  @seconds_per_minute 60
  @minutes_per_hour 60
  @hours_per_day 24
  @days_per_week 7
  @token_max_age 2 * @days_per_week * @hours_per_day * @minutes_per_hour *
                 @seconds_per_minute

  def connect(%{"token" => token}, socket) do
    case Phoenix.Token.verify(socket, "user", token, max_age: @token_max_age) do
      {:ok, {id, socket_token}} ->
        connect_user_id_and_socket_token(socket, id, socket_token)
      {:error, reason} ->
        :error
    end
  end

  defp connect_user(socket, user) do
    socket = assign(socket, :user_id, user.id)
    {:ok, socket}
  end

  defp connect_user_id_and_socket_token(socket, id, socket_token) do
    user = Repo.get_by!(User, id: id, socket_token: socket_token)
    connect_user socket, user
  end
end

                        

UserSocket.id/1


defmodule RoutingSecurelyWithPhoenixFramework.UserSocket do
  @doc """
  Group all sockets for a given user together so they can be disconnected.

  # Disconnecting a compromised user

      iex> RoutingSecurelyWithPhoenixFramework.Endpoint.broadcast("user_sockets:" <> user_id, "disconnect", %{})
  """
  def id(socket) do
    "user_sockets:#{socket.assigns.user_id}"
  end
end
                        

RoomChannel


defmodule RoutingSecurelyWithPhoenixFramework.RoomChannel do
  use RoutingSecurelyWithPhoenixFramework.Web, :channel

  def join("rooms:lobby", _payload, socket) do
    {:ok, socket}
  end

  @doc """
  Get user from `socket` before handling event.
  """
  def handle_in(event, params, socket) do
    user = Repo.get! User, socket.assigns.user_id
    handle_in(event, params, user, socket)
  end

  @doc """
  Broadcast a message from one user to all users (including the originating user)
  """
  def handle_in("new_message", %{"body" => body}, user, socket) do
    broadcast! socket,
               "new_message",
               %{
                  body: body,
                  user: %{
                    name: user.name
                  }
               }
    {:noreply, socket}
  end
end
                        

Demo

XSS

XSS

Cross-Site Scripting

  1. Chuck posts a message containing <script> tag
  2. Kronic Deth views message and script executes in his browser
  3. Chuck can now act as Kronic Deth

Proof-of-Concept


<script>
  $ = require("jquery");
  userName = $("nav span.label-info").text().trim();
  
  if (userName !== "Chuck") {
    socketToken = $('meta[name="socket_token"]').attr("content");
    
    phoenix = require("deps/phoenix/web/static/js/phoenix");
    socket = new phoenix.Socket("/socket");
    socket.connect({ token: socketToken});
    channel = socket.channel("rooms:lobby", {});
    channel.join().receive("ok", function (response) {
      channel.push("new_message", { body: "Message sent by Chuck's XSS" });
    });
  }
</script>                            
                        

XSS Prevention

  • Never Insert Untrusted Data
  • Escaping untrusted data must be tuned to the insertion environment
  • Don't write your own escaping library

Escaping Location

  1. Client-side

    • Clients can tune escaping to insertion point
    • Each client has to do the escaping
  2. Server-side

    • Server escapes once and all clients can use data directly
    • Don't have to worry about clients forgetting to escape
    • Server has to assume insert point type on all clients

Escaping Channel Messages


defmodule RoutingSecurelyWithPhoenixFramework.RoomChannel do
  @doc """
  Broadcast a message from one user to all users (including the originating user)
  """
  def handle_in("new_message", %{"body" => body}, user, socket) do
    broadcast! socket,
               "new_message",
               %{
                  body: Plug.HTML.html_escape(body),
                  user: %{
                    name: Plug.HTML.html_escape(user.name)
                  }
               }
    {:noreply, socket}
  end
end
                        

Demo

Compromise Recovery

Remote Shell

  1. Run server on a named node with a known cookie

    iex --cookie $COOKIE --name server@$HOST -S mix phoenix.server
  2. Connect with remote shell

    iex --cookie $COOKIE --hidden --name console@$HOST --remsh server@$HOST

Evict Attacker using Remote Shell

  1. Setup some aliases

    
    alias RoutingSecurelyWithPhoenixFramework.Endpoint
    alias RoutingSecurelyWithPhoenixFramework.Repo
    alias RoutingSecurelyWithPhoenixFramework.User
                                    
  2. Delete attacker's account

    
    user = Repo.get_by!(User, name: "Chuck")
    Repo.delete!(user)
                                    
  3. Disconnect attacker's sockets

    
    Endpoint.broadcast("user_sockets:#{user.id}", "disconnect", %{})
                                    

Secure Victim using Remote Shell

  1. Reset Password

    
    user = Repo.get_by!(User, name: "Kronic Deth")
    changeset = User.changeset user,
                               %{
                                 "password" => new_password,
                                 "password_confirmation" => new_password
                               }
                                    
  2. Reset Socket Token

    
    full_changeset = User.full_changeset(changeset)
    Repo.update!(full_changeset)
                                    
  3. Disconnect Kronic Deth's active sockets

    
    Endpoint.broadcast("user_sockets:#{user.id}", "disconnect", %{})
                                    

Invalidating Sessions

  1. Reset Kronic Deth's session
  2. Reset secret_key_base

Authenticate (Review)


defmodule RoutingSecurelyWithPhoenixFramework.Plug.Authenticate
  defp assign_current_user(conn = %Plug.Conn{}) do
    assign_current_user(conn, Plug.Conn.get_session(conn, :current_user_id))
  end
  defp assign_current_user(conn, id) when is_integer(id) do
    assign_current_user(conn, Repo.get(User, id))
  end
  defp assign_current_user(conn, user = %User{}) do
    Plug.Conn.assign(conn, :current_user, user)
  end
  defp assign_current_user(conn, _), do: redirect_to_sign_in(conn)

  defp redirect_to_sign_in(conn) do
    conn
    |> Phoenix.Controller.put_flash(
         :error,
         'You need to be signed in to view this page'
       )
    |> Phoenix.Controller.redirect(to: session_path(conn, :new))
    |> Plug.Conn.halt
  end
end
                        

Authentication Tokens

  • Tamper evident
  • Expire
  • Revocable by server

Add session_token to users


defmodule RoutingSecurelyWithPhoenixFramework.Repo.Migrations.AddSessionTokenToUser do
  use Ecto.Migration

  def down do
    alter table(:users) do
      remove :session_token
    end
  end

  def up do
    alter table(:users) do
      # add session_token as null because it needs to be populated first
      add :session_token, :string, null: true
    end

    # populate with secure random value using same algorithm as `mix phoenix.gen.secret`
    execute "CREATE EXTENSION IF NOT EXISTS pgcrypto"
    execute "UPDATE users SET session_token = encode(gen_random_bytes(64), 'base64') WHERE session_token IS NULL"

    alter table(:users) do
      modify :session_token, :string, null: false
    end
  end
end
                        

Adding session_token to User schema


defmodule RoutingSecurelyWithPhoenixFramework.User do
  schema "users" do
    field :session_token, :string
  end
end
                        

User.full_changeset/1


defmodule RoutingSecurelyWithPhoenixFramework.User do
  @token_length 64

  @doc """
  Fills `changeset` with generated columns.
  """
  def full_changeset(changeset) do
    changeset
    |> generate_password
    |> generate_token(:session)
    |> generate_token(:socket)
  end

  @doc """
  Generates a token to allow `type` credentials to be revoked.
  """
  def generate_token(changeset, type) do
    put_change(changeset, :"#{type}_token", generate_secret(@token_length))
  end
end
                        

Authenticate with revocation


defmodule RoutingSecurelyWithPhoenixFramework.Plug.Authenticate do
  @doc """
  Stores `user` in session so that `call` can retrieve it.
  """
  def put_session(conn, user) do
    conn
    |> Plug.Conn.put_session(:current_user_id, user.id)
    |> Plug.Conn.put_session(:current_user_session_token, user.session_token)
  end

  # Private Functions

  defp assign_current_user(conn = %Plug.Conn{}) do
    assign_current_user conn,
                        Plug.Conn.get_session(conn, :current_user_id),
                        Plug.Conn.get_session(conn, :current_user_session_token)
  end

  defp assign_current_user(conn, id, session_token)
       when is_integer(id) and is_binary(session_token) do
    assign_current_user conn,
                        Repo.get_by(User, id: id, session_token: session_token)
  end
  defp assign_current_user(conn, _, _), do: redirect_to_sign_in(conn)

  defp assign_current_user(conn, user = %User{}) do
    Plug.Conn.assign(conn, :current_user, user)
  end
  defp assign_current_user(conn, _), do: redirect_to_sign_in(conn)
end
                        

RegistrationController.create


defmodule RoutingSecurelyWithPhoenixFramework.RegistrationController do
  def create(conn, %{"user" => user_params}) do
    changeset = User.changeset(%User{}, user_params)

    if changeset.valid? do
      full_changeset = User.full_changeset(changeset)

      case Repo.insert(full_changeset) do
        {:ok, user} ->
          conn
          |> put_flash(:info, "Successfully registered and logged in")
          |> Authenticate.put_session(user)
          |> redirect(to: page_path(conn, :index))
        {:error, changeset} ->
          render conn, "new.html", changeset: changeset
      end
    else
      render conn, "new.html", changeset: changeset
    end
  end
end
                        

SessionController.sign_in


defmodule RoutingSecurelyWithPhoenixFramework.SessionController do
  defp sign_in(user, password, conn) when is_map(user) do
    if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
      conn
      |> put_flash(:info, "You are now signed in.")
      |> Authenticate.put_session(user)
      |> redirect(to: page_path(conn, :index))
    else
      conn
      |> put_flash(:error, @sign_in_error)
      |> render "new.html", changeset: User.changeset(%User{})
    end
  end
end
                        

Compromise Recovery

  • Ensure authentication tokens are revocable
  • Delete attacker account
  • Remove attackers from system
  • Reset victim accounts
  • Force accounts to log back in

Summary

Vulnerability Protection
Database password being disclosed from Github search config/*.secret.exs
Cookie secret_key_base being disclosed from Github search config/*.secret.exs
Password being sniffed on the network TLS
Cookies being sniffed on the network TLS
CSRF in HTML forms form_for adds hidden _csrf_token input
XSS in HTML views Phoenix.HTML does escaping for *.html.eex
XSS in channel messages Plug.Conn.html_escape explicitly
Session can't be revoked Store token in session and check against database in authentication plug
Socket access can't be revoked Store token in signed-token and check against database in connect/2
Socket access can't be revoked until new websocket connection Non-nil id/1 and Endpoint.broadcast(id, "disconnect", %{})

Bibliography