Packages
localize
0.21.0
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.41.3
0.41.2
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.1
0.30.0
retired
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
0.1.0-alpha.1
Localization (parsing, formatting) of numbers, dates/time/calendar, units of measure, messages and lists. Includes localized collation.
Current section
Files
Jump to
Current section
Files
lib/localize/utils/http.ex
defmodule Localize.Utils.Http do
@moduledoc """
Supports securely downloading HTTPS content.
This module provides HTTP GET functionality using the built-in `:httpc`
client with certificate verification enabled by default. It follows
the [erlef security guidelines](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl)
for secure TLS connections.
The primary public API consists of:
* `get/2` - download content from a URL, returning the body on success.
* `get_with_headers/2` - download content from a URL, returning both
headers and body on success.
* `certificate_locations/0` - return the list of possible certificate
store locations.
"""
require Logger
@localize_unsafe_https "LOCALIZE_UNSAFE_HTTPS"
@default_timeout "120000"
@default_connection_timeout "60000"
@doc """
Securely download HTTPS content from a URL.
This function uses the built-in `:httpc` client but enables certificate
verification which is not enabled by `:httpc` by default.
See also https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl
### Arguments
* `url` is a binary URL or a `{url, list_of_headers}` tuple. If
provided the headers are a list of `{'header_name', 'header_value'}`
tuples. Note that the name and value are both charlists, not
strings.
* `options` is a keyword list of options.
### Options
* `:verify_peer` is a boolean value indicating if peer verification
should be done for this request. The default is `true` in which case
the default `:ssl` options follow the
[erlef guidelines](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl).
* `:timeout` is the number of milliseconds available for the request
to complete. The default is #{inspect(@default_timeout)}. This option
may also be set with the `LOCALIZE_HTTP_TIMEOUT` environment variable.
* `:connection_timeout` is the number of milliseconds available for a
connection to be established to the remote host. The default is
#{inspect(@default_connection_timeout)}. This option may also be set
with the `LOCALIZE_HTTP_CONNECTION_TIMEOUT` environment variable.
### Returns
* `{:ok, body}` if the return is successful.
* `{:not_modified, headers}` if the request would result in returning
the same results as one matching an etag.
* `{:error, error}` if the download is unsuccessful. An error will
also be logged in these cases.
### Unsafe HTTPS
If the environment variable `LOCALIZE_UNSAFE_HTTPS` is set to anything
other than `"FALSE"`, `"false"`, `"nil"` or `"NIL"` then no peer
verification of certificates is performed. Setting this variable is
not recommended but may be required where peer verification fails for
unidentified reasons.
### Certificate stores
In order to keep dependencies to a minimum, `get/2` attempts to locate
an already installed certificate store. It will try to locate a store
in the following order which is intended to satisfy most host systems.
The certificate store is expected to be a path name on the host system.
```elixir
# A certificate store configured by the developer
Application.get_env(:localize, :cacertfile)
# Populated if hex package `CAStore` is configured
CAStore.file_path()
# Populated if hex package `certifi` is configured
:certifi.cacertfile()
# Debian/Ubuntu/Gentoo etc.
"/etc/ssl/certs/ca-certificates.crt"
# Fedora/RHEL 6
"/etc/pki/tls/certs/ca-bundle.crt"
# OpenSUSE
"/etc/ssl/ca-bundle.pem"
# OpenELEC
"/etc/pki/tls/cacert.pem"
# CentOS/RHEL 7
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem"
# OpenSSL on MacOS
"/usr/local/etc/openssl/cert.pem"
# MacOS & Alpine Linux
"/etc/ssl/cert.pem"
```
"""
@spec get(String.t() | {String.t(), list()}, options :: keyword()) ::
{:ok, binary()} | {:not_modified, any()} | {:error, any()}
def get(url, options \\ [])
def get(url, options) when is_binary(url) and is_list(options) do
case get_with_headers(url, options) do
{:ok, _headers, body} -> {:ok, body}
other -> other
end
end
def get({url, headers}, options)
when is_binary(url) and is_list(headers) and is_list(options) do
case get_with_headers({url, headers}, options) do
{:ok, _headers, body} -> {:ok, body}
other -> other
end
end
@doc """
Securely download HTTPS content from a URL, returning headers and body.
This function uses the built-in `:httpc` client but enables certificate
verification which is not enabled by `:httpc` by default.
See also https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl
### Arguments
* `url` is a binary URL or a `{url, list_of_headers}` tuple. If
provided the headers are a list of `{'header_name', 'header_value'}`
tuples. Note that the name and value are both charlists, not
strings.
* `options` is a keyword list of options.
### Options
* `:verify_peer` is a boolean value indicating if peer verification
should be done for this request. The default is `true` in which case
the default `:ssl` options follow the
[erlef guidelines](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/ssl).
* `:timeout` is the number of milliseconds available for the request
to complete. The default is #{inspect(@default_timeout)}. This option
may also be set with the `LOCALIZE_HTTP_TIMEOUT` environment variable.
* `:connection_timeout` is the number of milliseconds available for a
connection to be established to the remote host. The default is
#{inspect(@default_connection_timeout)}. This option may also be set
with the `LOCALIZE_HTTP_CONNECTION_TIMEOUT` environment variable.
* `:https_proxy` is the URL of an HTTPS proxy to be used. The
default is `nil`.
### Returns
* `{:ok, headers, body}` if the return is successful.
* `{:not_modified, headers}` if the request would result in returning
the same results as one matching an etag.
* `{:error, error}` if the download is unsuccessful. An error will
also be logged in these cases.
### HTTPS Proxy
`Localize.Utils.Http.get_with_headers/2` will look for a proxy URL in
the following locations in the order presented:
* `options[:https_proxy]`
* Localize compile-time configuration under the
key `:localize[:https_proxy]`.
* The environment variable `HTTPS_PROXY`.
* The environment variable `https_proxy`.
"""
@spec get_with_headers(String.t() | {String.t(), list()}, options :: keyword()) ::
{:ok, list(), binary()} | {:not_modified, any()} | {:error, any()}
def get_with_headers(request, options \\ [])
def get_with_headers(url, options) when is_binary(url) do
get_with_headers({url, []}, options)
end
def get_with_headers({url, headers}, options)
when is_binary(url) and is_list(headers) and is_list(options) do
hostname = String.to_charlist(URI.parse(url).host)
url = String.to_charlist(url)
http_options = http_options(hostname, options)
https_proxy = https_proxy(options)
ip_family = :inet6fb4
if https_proxy do
case URI.parse(https_proxy) do
%{host: host, port: port} when is_binary(host) and is_integer(port) ->
:ok =
:httpc.set_options(
https_proxy: {{String.to_charlist(host), port}, []},
ipfamily: ip_family
)
_other ->
Logger.warning(
"https_proxy was set to an invalid value. Found #{inspect(https_proxy)}."
)
end
else
:ok = :httpc.set_options(ipfamily: ip_family)
end
case :httpc.request(:get, {url, headers}, http_options, body_format: :binary) do
{:ok, {{_version, 200, _}, headers, body}} ->
{:ok, headers, body}
{:ok, {{_version, 304, _}, headers, _body}} ->
{:not_modified, headers}
{_, {{_version, code, message}, _headers, _body}} ->
Logger.error(
"Failed to download #{url}. " <>
"HTTP Error: (#{code}) #{inspect(message)}"
)
{:error, code}
{:error,
{:failed_connect, [{:to_address, {host, _port}}, {:inet6, _, _}, {_, _, :timeout}]}} ->
Logger.error(
"Timeout connecting to #{inspect(host)} to download #{inspect(url)}. " <>
"Connection time exceeded #{http_options[:connect_timeout]}ms."
)
{:error, :connection_timeout}
{:error,
{:failed_connect, [{:to_address, {host, _port}}, {:inet6, _, _}, {_, _, :nxdomain}]}} ->
Logger.error("Failed to resolve host #{inspect(host)} to download #{inspect(url)}")
{:error, :nxdomain}
{:error, :timeout} ->
Logger.error(
"Timeout downloading from #{inspect(url)}. " <>
"Request exceeded #{http_options[:timeout]}ms."
)
{:error, :timeout}
{:error, other} ->
Logger.error("Failed to download #{inspect(url)}. Error #{inspect(other)}")
{:error, other}
end
end
@static_certificate_locations [
# Debian/Ubuntu/Gentoo etc.
"/etc/ssl/certs/ca-certificates.crt",
# Fedora/RHEL 6
"/etc/pki/tls/certs/ca-bundle.crt",
# OpenSUSE
"/etc/ssl/ca-bundle.pem",
# OpenELEC
"/etc/pki/tls/cacert.pem",
# CentOS/RHEL 7
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
# OpenSSL on MacOS
"/usr/local/etc/openssl/cert.pem",
# MacOS & Alpine Linux
"/etc/ssl/cert.pem"
]
@doc """
Return the dynamically discovered certificate file locations.
These include application configuration and optional hex packages
such as `CAStore` and `certifi`.
### Returns
* A list of file path strings for discovered certificate locations.
"""
@spec dynamic_certificate_locations() :: [String.t()]
def dynamic_certificate_locations do
[
# Configured cacertfile
Application.get_env(:localize, :cacertfile),
# Populated if hex package CAStore is configured
if(Code.ensure_loaded?(CAStore), do: apply(CAStore, :file_path, [])),
# Populated if hex package certifi is configured
if(Code.ensure_loaded?(:certifi),
do: apply(:certifi, :cacertfile, []) |> List.to_string()
)
]
|> Enum.reject(&is_nil/1)
end
@doc """
Return all possible locations of a certificate file.
Returns dynamically discovered locations followed by well-known
static locations on common operating systems.
### Returns
* A list of file path strings for all candidate certificate locations.
"""
@spec certificate_locations() :: [String.t()]
def certificate_locations do
dynamic_certificate_locations() ++ @static_certificate_locations
end
@doc false
@spec certificate_store() :: String.t() | no_return()
def certificate_store do
certificate_locations()
|> Enum.find(&File.exists?/1)
|> raise_if_no_cacertfile!()
end
defp raise_if_no_cacertfile!(nil) do
raise RuntimeError, """
No certificate trust store was found.
Tried looking for: #{inspect(certificate_locations())}
A certificate trust store is required in
order to download data for your configuration.
Since Localize could not detect a system
installed certificate trust store one of the
following actions may be taken:
1. Install the hex package `castore`. It will
be automatically detected after recompilation.
2. Install the hex package `certifi`. It will
be automatically detected after recompilation.
3. Specify the location of a certificate trust store
by configuring it in `config.exs` or `runtime.exs`:
config :localize,
cacertfile: "/path/to/cacertfile",
...
"""
end
defp raise_if_no_cacertfile!(file) do
file
end
defp http_options(hostname, options) do
default_timeout =
"LOCALIZE_HTTP_TIMEOUT"
|> System.get_env(@default_timeout)
|> String.to_integer()
default_connection_timeout =
"LOCALIZE_HTTP_CONNECTION_TIMEOUT"
|> System.get_env(@default_connection_timeout)
|> String.to_integer()
verify_peer? = Keyword.get(options, :verify_peer, true)
ssl_options = https_ssl_options(hostname, verify_peer?)
timeout = Keyword.get(options, :timeout, default_timeout)
connection_timeout = Keyword.get(options, :connection_timeout, default_connection_timeout)
[timeout: timeout, connect_timeout: connection_timeout, ssl: ssl_options]
end
defp https_ssl_options(hostname, verify_peer?) do
if secure_ssl?() and verify_peer? do
[
verify: :verify_peer,
cacertfile: certificate_store(),
depth: 4,
ciphers: preferred_ciphers(),
versions: protocol_versions(),
eccs: preferred_eccs(),
reuse_sessions: true,
server_name_indication: hostname,
secure_renegotiate: true,
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
else
[
verify: :verify_none,
server_name_indication: hostname,
secure_renegotiate: true,
reuse_sessions: true,
versions: protocol_versions(),
ciphers: preferred_ciphers()
]
end
end
defp preferred_ciphers do
preferred_ciphers = [
# Cipher suites (TLS 1.3)
%{cipher: :aes_128_gcm, key_exchange: :any, mac: :aead, prf: :sha256},
%{cipher: :aes_256_gcm, key_exchange: :any, mac: :aead, prf: :sha384},
%{cipher: :chacha20_poly1305, key_exchange: :any, mac: :aead, prf: :sha256},
# Cipher suites (TLS 1.2)
%{cipher: :aes_128_gcm, key_exchange: :ecdhe_ecdsa, mac: :aead, prf: :sha256},
%{cipher: :aes_128_gcm, key_exchange: :ecdhe_rsa, mac: :aead, prf: :sha256},
%{cipher: :aes_256_gcm, key_exchange: :ecdh_ecdsa, mac: :aead, prf: :sha384},
%{cipher: :aes_256_gcm, key_exchange: :ecdh_rsa, mac: :aead, prf: :sha384},
%{cipher: :chacha20_poly1305, key_exchange: :ecdhe_ecdsa, mac: :aead, prf: :sha256},
%{cipher: :chacha20_poly1305, key_exchange: :ecdhe_rsa, mac: :aead, prf: :sha256},
%{cipher: :aes_128_gcm, key_exchange: :dhe_rsa, mac: :aead, prf: :sha256},
%{cipher: :aes_256_gcm, key_exchange: :dhe_rsa, mac: :aead, prf: :sha384}
]
:ssl.filter_cipher_suites(preferred_ciphers, [])
end
defp protocol_versions do
[:"tlsv1.2", :"tlsv1.3"]
end
defp preferred_eccs do
preferred_eccs = [:secp256r1, :secp384r1]
:ssl.eccs() -- (:ssl.eccs() -- preferred_eccs)
end
defp secure_ssl? do
case System.get_env(@localize_unsafe_https) do
nil -> true
"FALSE" -> false
"false" -> false
"nil" -> false
"NIL" -> false
_other -> true
end
end
defp https_proxy(options) do
options[:https_proxy] ||
Application.get_env(:localize, :https_proxy) ||
System.get_env("HTTPS_PROXY") ||
System.get_env("https_proxy")
end
end