SafeRedirect (SafeRedirect v2.0.0)

Copy Markdown View Source

Validates and resolves redirect URLs to prevent Open Redirect vulnerabilities.

  • valid_url?/2 returns whether a URL is allowed.
  • resolve_url/3 returns the URL if it is allowed, or a default value if it is not.
  • redirect/4 resolves the URL and performs the redirect.

Allowed redirect URIs

All functions take an :allowed_redirect_uris option. The value is a list of strings or URI structs, a single string or URI struct, or a {module, function} tuple that returns such a list. The function is called every time a URL is validated.

SafeRedirect.valid_url?(url,
  allowed_redirect_uris: ["https://good.example"]
)

SafeRedirect.valid_url?(url,
  allowed_redirect_uris: {MyAppWeb.RedirectURIs, :allowed_redirect_uris}
)

If the option is omitted, the value is read from the application environment at call time, defaulting to an empty list.

config :safe_redirect,
  allowed_redirect_uris: {MyAppWeb.RedirectURIs, :allowed_redirect_uris}

The application environment is a global default for the common case of one allowlist per system, and the option overrides it wherever a call needs something else. It is read on every call rather than at compile time, so it can be set in runtime.exs.

Validation rules

  • Relative paths starting with / are allowed without checking the allowed URIs.
  • Absolute URLs are allowed if the scheme, host, and port match one of the allowed URIs. The scheme and host are compared case-insensitively.
  • A trailing dot is part of the host, so https://good.example. does not match an allowed https://good.example.
  • Protocol-relative URLs starting with // are not allowed.
  • A URL with a scheme but no host is not allowed. mailto:, javascript:, and data: URLs are always refused.
  • Paths must not contain dot segments (. or ..), literal or encoded.
  • Paths must not contain control characters, literal or encoded.
  • Percent-encoded characters are decoded before a path is checked, so /some%2Fpath is treated as /some/path.
  • An allowed URI may not have a path, query string, fragment, or userinfo, since only the scheme, host, and port are compared.

An invalid :allowed_redirect_uris option raises ArgumentError in every function.

A URL given as a string is parsed with URI.new/1, which rejects a host that is not ASCII. A URI struct is taken as given. If you build a URI struct from a string, use URI.new/1 rather than URI.parse/1, which does not reject such a host.

Summary

Types

The value of the :allowed_redirect_uris option.

Options accepted by all functions.

A URL, either as a string or as a URI struct.

Functions

Resolves the given URL and performs an internal or external redirect.

Returns the given URL if it is a valid redirect URL or the default value otherwise.

Takes a URL as a string or URI struct and determines whether it points to an allowed host.

Types

allowed_redirect_uris()

@type allowed_redirect_uris() :: [uri_source()] | uri_source() | {module(), atom()}

The value of the :allowed_redirect_uris option.

See the module documentation for the accepted shapes.

opts()

@type opts() :: [{:allowed_redirect_uris, allowed_redirect_uris()}]

Options accepted by all functions.

uri_source()

@type uri_source() :: String.t() | URI.t()

A URL, either as a string or as a URI struct.

Functions

redirect(conn_or_socket, url, default \\ "/", opts \\ [])

@spec redirect(Plug.Conn.t(), term(), uri_source(), opts()) :: Plug.Conn.t()
@spec redirect(
  Phoenix.LiveView.Socket.t(),
  term(),
  uri_source(),
  opts()
) :: Phoenix.LiveView.Socket.t()

Resolves the given URL and performs an internal or external redirect.

Resolves url against the allowed URIs with resolve_url/3, falling back to default, then redirects to the result.

Accepts a Plug.Conn or, when Phoenix.LiveView is available, a Phoenix.LiveView.Socket, and returns the same type it was given.

A root-relative path is an internal redirect and an absolute http or https URL an external one. For a LiveView socket these are passed to Phoenix.LiveView.redirect/2 as to: and external:; for a Plug.Conn both set the location header.

Given a Plug.Conn, the connection is halted.

The default value is not validated against the allowed URIs, so it must not come from user input.

Raises ArgumentError if the resolved URL is neither a relative path nor an http or https URL, for example if the default value is nil or if an allowed URI uses a different scheme. That is the only check applied to the default value.

Examples

Using configuration via application environment:

redirect(conn, url, "/default")

Passing allowed URIs directly:

redirect(
  conn,
  url,
  "/default",
  allowed_redirect_uris: ["https://good.example"]
)

resolve_url(url, default \\ "/", opts \\ [])

@spec resolve_url(term(), uri_source() | nil, opts()) :: uri_source() | nil

Returns the given URL if it is a valid redirect URL or the default value otherwise.

The URL is returned unchanged, so passing a URI struct returns a URI struct. Any other value, including nil, returns the default value.

The default value is returned as given and is not validated against the allowed URIs, so it must not come from user input.

See the module documentation for the validation rules and the :allowed_redirect_uris option.

Examples

iex> url = "https://good.example/login"
iex> resolve_url(url, "/", allowed_redirect_uris: ["https://good.example"])
"https://good.example/login"

iex> url = "/login"
iex> resolve_url(url, "/", allowed_redirect_uris: ["https://good.example"])
"/login"

iex> url = "https://evil.example/login"
iex> resolve_url(url, "/", allowed_redirect_uris: ["https://good.example"])
"/"

valid_url?(url, opts \\ [])

@spec valid_url?(uri_source() | nil, opts()) :: boolean()

Takes a URL as a string or URI struct and determines whether it points to an allowed host.

Relative paths are allowed without checking the allowed URIs. See the module documentation for the validation rules and the :allowed_redirect_uris option.

Examples

iex> url = "https://good.example/login"
iex> valid_url?(url, allowed_redirect_uris: ["https://good.example"])
true

iex> url = "/login"
iex> valid_url?(url, allowed_redirect_uris: ["https://good.example"])
true

iex> url = "https://evil.example/login"
iex> valid_url?(url, allowed_redirect_uris: ["https://good.example"])
false