Security Best Practices

View Source

Ensure secure usage of Mau templates in your applications.

Overview

This guide covers security considerations when using Mau templates, including input validation, output encoding, and protection against common vulnerabilities.

Input Validation

Validate Template Source

Only use trusted template sources:

# ❌ DANGEROUS: User-provided templates
defmodule MyApp.Unsafe do
  def render_user_template(user_input) do
    # NEVER do this with untrusted input
    Mau.render(user_input, context)
  end
end

# ✅ SAFE: Pre-compiled templates only
defmodule MyApp.Safe do
  @templates %{
    "welcome" => load_template("welcome.html"),
    "invoice" => load_template("invoice.html")
  }

  def render_template(template_name, context) do
    case @templates[template_name] do
      ast when is_tuple(ast) -> Mau.render(ast, context)
      nil -> {:error, "Template not found"}
    end
  end

  defp load_template(filename) do
    path = Path.join(["templates", filename])
    content = File.read!(path)
    {:ok, ast} = Mau.compile(content)
    ast
  end
end

Whitelist Templates

Maintain an explicit allowlist:

defmodule MyApp.TemplateRegistry do
  @allowed_templates [
    "user_profile",
    "order_confirmation",
    "invoice",
    "report_summary"
  ]

  def get_template(name) do
    if Enum.member?(@allowed_templates, name) do
      load_from_file(name)
    else
      {:error, "Template not allowed"}
    end
  end

  defp load_from_file(name) do
    path = Path.join(["priv", "templates", "#{name}.html"])
    File.read!(path)
  end
end

Context Security

Prevent Information Disclosure

Don't expose sensitive data in context:

# ❌ Dangerous: Exposes sensitive data
def render_user_page(user) do
  context = %{
    "user" => user  # Includes password_hash, api_keys, etc.
  }
  Mau.render(template, context)
end

# ✅ Safe: Only expose necessary data
def render_user_page(user) do
  context = %{
    "user" => %{
      "id" => user.id,
      "name" => user.name,
      "email" => user.email
    }
  }
  Mau.render(template, context)
end

Sanitize User Input

Clean user-provided data before using in templates:

defmodule MyApp.ContextSanitizer do
  def sanitize_context(raw_context) do
    raw_context
    |> Enum.map(fn {key, value} ->
      {key, sanitize_value(value)}
    end)
    |> Map.new()
  end

  defp sanitize_value(value) when is_binary(value) do
    value
    |> String.trim()
    |> truncate_length(1000)  # Prevent DoS
    |> escape_html()
  end

  defp sanitize_value(value) when is_list(value) do
    Enum.map(value, &sanitize_value/1)
  end

  defp sanitize_value(value) when is_map(value) do
    Enum.map(value, fn {k, v} -> {k, sanitize_value(v)} end)
    |> Map.new()
  end

  defp sanitize_value(value), do: value

  defp truncate_length(string, max_length) do
    if String.length(string) > max_length do
      String.slice(string, 0, max_length)
    else
      string
    end
  end

  defp escape_html(string) do
    string
    |> String.replace("&", "&")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&#x27;")
  end
end

# Usage
context = MyApp.ContextSanitizer.sanitize_context(user_input)
{:ok, output} = Mau.render(template, context)

Output Encoding

HTML Escaping

Always escape HTML content:

# ❌ Dangerous: XSS vulnerability
template = """
<p>{{ user_comment }}</p>
"""
context = %{"user_comment" => "<script>alert('XSS')</script>"}
{:ok, output} = Mau.render(template, context)
# Output: <p><script>alert('XSS')</script></p> ← Script executes!

# ✅ Safe: Use escaping filter
template = """
<p>{{ user_comment | escape_html }}</p>
"""
context = %{"user_comment" => "<script>alert('XSS')</script>"}
{:ok, output} = Mau.render(template, context)
# Output: <p>&lt;script&gt;alert(&#x27;XSS&#x27;)&lt;/script&gt;</p> ← Safe

Context-Specific Encoding

Use appropriate encoding for context:

defmodule MyApp.OutputEncoders do
  # HTML context
  def html_escape(value) when is_binary(value) do
    value
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
    |> String.replace("'", "&#x27;")
  end

  # JavaScript context
  def js_escape(value) when is_binary(value) do
    value
    |> String.replace("\\", "\\\\")
    |> String.replace("\"", "\\\"")
    |> String.replace("'", "\\'")
    |> String.replace("\n", "\\n")
    |> String.replace("\r", "\\r")
  end

  # URL context
  def url_encode(value) when is_binary(value) do
    URI.encode(value)
  end

  # CSV context
  def csv_escape(value) when is_binary(value) do
    if String.contains?(value, [",", "\"", "\n"]) do
      "\"" <> String.replace(value, "\"", "\"\"") <> "\""
    else
      value
    end
  end
end

Prevent Injection Attacks

Template Injection

Prevent injection of template syntax:

defmodule MyApp.InjectionPrevention do
  # ❌ Dangerous: User input as template
  def render_unsafe(user_template) do
    Mau.render(user_template, %{})  # ← Template injection risk
  end

  # ✅ Safe: User input as context only
  def render_safe(template, user_input) do
    context = %{"user_input" => user_input}  # ← User data only
    Mau.render(template, context)
  end

  # ✅ Safer: Validate user input
  def render_validated(template, user_input) do
    if is_safe_context_value?(user_input) do
      context = %{"user_input" => user_input}
      Mau.render(template, context)
    else
      {:error, "Invalid input"}
    end
  end

  defp is_safe_context_value?(value) when is_binary(value) do
    !String.contains?(value, ["{{", "{%", "{#"])
  end

  defp is_safe_context_value?(value) when is_list(value) do
    Enum.all?(value, &is_safe_context_value?/1)
  end

  defp is_safe_context_value?(value) when is_map(value) do
    Enum.all?(value, fn {_k, v} -> is_safe_context_value?(v) end)
  end

  defp is_safe_context_value?(_), do: true
end

Filter Injection

Restrict available filters:

defmodule MyApp.FilterRestriction do
  @allowed_filters [
    "upper_case",
    "lower_case",
    "truncate",
    "join",
    "split",
    "strip"
  ]

  # ✅ Only allow specific filters
  def validate_template(template) do
    template
    |> extract_filters()
    |> Enum.all?(&Enum.member?(@allowed_filters, &1))
  end

  defp extract_filters(template) do
    Regex.scan(~r/\|\s*(\w+)/, template)
    |> Enum.map(fn [_full, filter_name] -> filter_name end)
  end
end

Rate Limiting

Prevent DoS Attacks

Limit template rendering:

defmodule MyApp.RateLimiter do
  @max_template_size 100_000  # 100 KB
  @max_loop_iterations 10_000
  @max_renders_per_minute 1000

  def render_limited(template, context) do
    case validate_limits(template) do
      :ok ->
        Mau.render(
          template,
          context,
          max_template_size: @max_template_size,
          max_loop_iterations: @max_loop_iterations
        )

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp validate_limits(template) when is_binary(template) do
    cond do
      byte_size(template) > @max_template_size ->
        {:error, "Template exceeds maximum size"}

      true ->
        :ok
    end
  end

  # Track renders per user
  def track_render(user_id) do
    key = "renders:#{user_id}:#{current_minute()}"
    count = increment_counter(key)

    if count > @max_renders_per_minute do
      {:error, "Rate limit exceeded"}
    else
      :ok
    end
  end

  defp current_minute do
    System.os_time(:second) |> div(60)
  end

  defp increment_counter(key) do
    # Use Redis or ETS for production
    :ets.update_counter(:rate_limits, key, 1)
  end
end

Secure Defaults

Configuration

# config/config.exs
config :mau,
  # Disable runtime templates
  enable_runtime_filters: false,

  # Limit template size
  max_template_size: 100_000,

  # Reasonable iteration limit
  max_loop_iterations: 10_000,

  # Only use built-in filters
  filters: [
    Mau.Filters.String,
    Mau.Filters.Collection,
    Mau.Filters.Math
  ]

Safe Defaults in Application Code

defmodule MyApp.SecureTemplates do
  @secure_defaults [
    preserve_types: false,
    max_template_size: 100_000,
    max_loop_iterations: 10_000
  ]

  def render(template, context) do
    Mau.render(template, context, @secure_defaults)
  end

  def render_map(input, context) do
    Mau.render_map(input, context, @secure_defaults)
  end
end

Security Checklist

Pre-Deployment

  • [ ] All templates from trusted sources only
  • [ ] User input validated before template use
  • [ ] Sensitive data removed from context
  • [ ] HTML output properly escaped
  • [ ] Custom filters reviewed for vulnerabilities
  • [ ] Rate limiting configured
  • [ ] Template size limits set
  • [ ] Loop iteration limits configured

Runtime

  • [ ] Error messages don't expose implementation details
  • [ ] Logging doesn't include sensitive data
  • [ ] Templates cached securely
  • [ ] Access control validates template requests
  • [ ] Monitoring alerts on suspicious activity

Testing

  • [ ] XSS prevention tested
  • [ ] Injection attacks tested
  • [ ] DoS scenarios tested
  • [ ] Error handling tested
  • [ ] Input validation tested

Common Vulnerabilities

Cross-Site Scripting (XSS)

# ❌ Vulnerable
<div>{{ user_comment }}</div>

# ✅ Safe
<div>{{ user_comment | escape_html }}</div>

Template Injection

# ❌ Vulnerable
Mau.render(user_provided_template, context)

# ✅ Safe
Mau.render(pre_compiled_template, sanitized_context)

Information Disclosure

# ❌ Vulnerable: Error shows implementation details
catch_all_error = fn error ->
  "Error: #{error}"  # Exposes internal details
end

# ✅ Safe: Generic error messages
safe_error = fn _error ->
  "An error occurred. Please try again."
end

Denial of Service

# ❌ Vulnerable: No limits
Mau.render(user_template, huge_context)

# ✅ Safe: Limits configured
Mau.render(
  user_template,
  huge_context,
  max_template_size: 100_000,
  max_loop_iterations: 10_000
)

Security Testing

XSS Testing

defmodule MyApp.SecurityTests do
  use ExUnit.Case

  test "escapes HTML special characters" do
    template = "<div>{{ content }}</div>"
    context = %{"content" => "<script>alert('XSS')</script>"}

    {:ok, output} = Mau.render(template, context)

    refute String.contains?(output, "<script>")
    assert String.contains?(output, "&lt;")
  end

  test "prevents template injection" do
    template = "{{ user_input }}"
    context = %{"user_input" => "{{ malicious }}"}

    {:ok, output} = Mau.render(template, context)

    # Should render as literal text, not as template
    assert output == "{{ malicious }}"
  end

  test "respects size limits" do
    large_template = String.duplicate("{{ var }}", 20_000)

    assert {:error, _} =
             Mau.render(large_template, %{}, max_template_size: 100_000)
  end
end

Resources


See Also