Require signed URLs, so the server serves only the URLs your application
generated, and replace the signing key later without breaking URLs in use.
This assumes ImagePipe running in your app or as image_pipe_server, with
URLs built by ImagePipe.URL. Signing and source concealment
explains what a signature covers.
Generate a key
A signing key is a hex string of at least 64 digits (32 bytes). A shorter key
stops image_pipe_server at startup and raises ArgumentError when ImagePipe
is configured in your app. Generate 32 random bytes:
openssl rand -hex 32
Store the result as a secret, for example in an environment variable named
IMAGE_PIPE_SIGNING_KEY. Keep the key on your servers.
Give each image service its own key. The hostname is outside the signature (what a signature covers), so services that share a key accept each other's URLs.
Configure the server
image_pipe_server
Pass the key in IPS_URL__KEYS, or in a file named by IPS_URL__KEYS_FILE
for Docker and Kubernetes secrets:
IPS_URL__KEYS=0123abcd…
The server reads it at startup. See
[url].
Plug
Give the instance the key. base_url, the path the router forwards to
ImagePipe.Plug, lets the builder in the next step produce full URLs:
# lib/my_app/application.ex
{ImagePipe,
name: MyApp.Images,
base_url: "/images",
keys: [System.fetch_env!("IMAGE_PIPE_SIGNING_KEY")],
sources: [...]}From now on, the server answers unsigned URLs with 403 and the body
invalid signature.
Sign URLs in the builder
In an app that serves its own URLs, build them from the instance's URL configuration, which already has the key:
url_config = ImagePipe.url_config(MyApp.Images)An application that builds URLs for a separate server uses the same key:
url_config =
ImagePipe.URL.config(
base_url: "https://img.example.com/images",
keys: [System.fetch_env!("IMAGE_PIPE_SIGNING_KEY")]
)Every URL generated from url_config is signed. With the second
configuration:
thumbnail = ImagePipe.URL.new(url_config) |> ImagePipe.URL.group(resize: [width: 400])
ImagePipe.URL.url!(thumbnail, "photos/beach.jpg")
# "https://img.example.com/images/sig=Sr0wHrHp…/w=400/src/photos%2Fbeach.jpg"Generate URLs in server code, such as a controller or template, and send the
browser only the finished URL. ImagePipe accepts any signed URL, so check a
value your app takes from a visitor, such as a width from a query string,
before you put it in a URL. Assign thumbnail to the template, then:
<img src={ImagePipe.URL.url!(@thumbnail, @photo.source)} />To sign a path you built yourself, pass it to ImagePipe.URL.sign_path/2
and prepend the base URL yourself:
"https://img.example.com/images" <> ImagePipe.URL.sign_path("/w=400/src/photos/beach.jpg", url_config).
Add an expiry
To make a URL stop working at a given time, pass expires as a Unix time in
seconds:
ImagePipe.URL.new(url_config, expires: System.os_time(:second) + 3600)After that second, the URL answers 410 with the body expired. A new
expiry gives a new URL, which browsers and CDNs can't serve from their
cache. To keep one URL per image for a while, round the expiry up, for
example to the end of the day.
Check that signing works
Request a generated URL, then the same URL with one option changed:
curl -s -o /dev/null -w "%{http_code}\n" "https://img.example.com/images/sig=Sr0wHrHp…/w=400/src/photos%2Fbeach.jpg"
# 200
curl -s -w "\n%{http_code}\n" "https://img.example.com/images/sig=Sr0wHrHp…/w=800/src/photos%2Fbeach.jpg"
# invalid signature
# 403
Rotate the key
During the switch, both lists hold the old and the new key:
- Generate a new key.
- Add the new key to the server's list, after the current one, and restart
the server:
IPS_URL__KEYS=<old>,<new>forimage_pipe_server, orkeys: [old, new]for the Plug. The server now accepts both keys. - Put the new key first in the builder's list and keep the old key second:
keys: [new, old]. New URLs are signed with the new key. - Wait until nothing requests URLs signed with the old key: after the
longest
expiresyou gave out, and after pages and caches holding those URLs have been refreshed. A URL withoutexpiresstays valid as long as the server has its key. - Remove the old key from both lists.
In an app that serves its own URLs, the server and the builder share one
list, so steps 2 and 3 are one change to keys: [new, old]. When several
nodes run the app, deploy keys: [old, new] to every node first, then
keys: [new, old], so no node signs with a key another node doesn't accept
yet.
To see which key verified each request, read sig_key in the request's log
line: the position of the key in the server's list, counting from 0. The
server logs a line per request by default. For the Plug, call
ImagePipe.Telemetry.attach_default_logger() at startup (see
Monitoring with telemetry).
[info] GET /sig=Sr0wHrHp…/w=400/src/photos%2Fbeach.jpg 200 41ms 4.6kB sig_key=1 cache=hit source=imagesDuring step 4, the server's list is [old, new], so sig_key=0 means a URL
signed with the old key was requested. With one shared [new, old] list,
the old key is sig_key=1. In your app, a custom handler reads the same
number as :sig_key from the
[:request, :summary] event.
Allow unsigned URLs
Unsigned URLs belong on a private network
Anyone who can reach a server that accepts unsigned URLs chooses every option of every request, and each request costs the server CPU and memory. Serve public traffic, including traffic through a CDN, with signed URLs.
Without signing keys, image_pipe_server doesn't start, and ImagePipe.Plug
raises ArgumentError on every request. For a server that only your own
services can reach, allow unsigned URLs:
image_pipe_server
[url]
allow_unsigned = truePlug
# lib/my_app/application.ex
{ImagePipe, name: MyApp.Images, allow_unsigned: true, sources: [...]}image_pipe_server and an ImagePipe instance then log a warning when they
start.
Next steps
- Shared URL settings: everything else the server and an external builder must agree on.
- Source concealment: hide the image's path in the URL as well.
- Serving images through a CDN: cache lifetimes for URLs with an expiry.