Serves attachment bytes, behind a signature.
plug Coelho.Plug.Attachments,
at: "/attachments",
storage: Coelho.Storage.Disk.new("priv/uploads"),
secret: {MyApp.Uploads, :secret, []},
metadata: {MyApp.Uploads, :metadata, []}Requests that do not match :at fall through untouched.
Options
:at— the path prefix to serve from, required:storage— aCoelho.Storage, or an{m, f, a}returning one, required. The{m, f, a}form matters in an endpoint, whereinit/1may run at compile time and a storage built then would freeze the configuration it was built from:secret— the signing secret, as a binary or an{m, f, a}read at request time, required. It has to match what built the URL; seeCoelho.Attachments.signed_url/4:metadata— an{m, f, a}called with the key, returning%{content_type: …, filename: …}ornil. Coelho does not hold a repo, so this is how the row reaches the response:authorize— an{m, f, a}or afun/2, called with the connection and the key once the signature has checked out. Return:okortrueto serve, anything else to refuse. See below
A signature says the URL is genuine, not that it is yours
A signed URL is a bearer token: whoever holds it, holds the file. That is the right answer for a single-tenant application and the wrong one the moment there is more than one tenant, because the signature is checked against the secret and the secret is the application's. A URL minted for one organisation, forwarded or logged or pasted, is served to anyone who replays it.
Mounting this behind the application's authentication pipeline does not close it. That answers "may this person use the application", never "is this file theirs": a signed URL belonging to organisation A, replayed by a signed-in member of organisation B, passes the pipeline and the signature. Do mount it behind authentication — this needs a connection that already knows who is asking — but do not mistake it for the check.
:authorize is the check:
plug Coelho.Plug.Attachments,
at: "/attachments",
storage: {MyApp.Uploads, :storage, []},
secret: {MyApp.Uploads, :secret, []},
authorize: {MyApp.Uploads, :authorize, []}
def authorize(conn, key) do
case conn.assigns[:current_organisation] do
nil -> :error
organisation -> MyApp.Uploads.owned_by?(key, organisation)
end
endThe organisation comes from the connection, and never from the key. The
key arrives in the URL, which is to say from whoever sent the request, so
deriving the tenant from it is asking the attacker which tenant they are
in — see Coelho.Attachment.generate_key/1 on why a key prefix is an
inventory aid and not this.
Without :authorize, the signature is the only thing between a request
and the bytes. That is deliberate, and it is documented rather than
defaulted, because a default here would either break every single-tenant
application or quietly do nothing.
What it does, exactly
Three things worth stating, because a security check is only worth what its edges are worth:
- It runs before anything is fetched or minted. The order is
signature, then
:authorize, then the bytes — so:metadatais not called for a request that will be refused, andCoelho.Storage.redirect_url/3is never asked for a presigned URL on behalf of a caller who is not allowed the file. A refusal costs the application one callback and no query. - A refusal is
403, with the same body a bad signature gets. Not404: the two answers are deliberately identical, because telling "this is not yours" apart from "this does not exist" tells the caller it exists. A404only ever comes from past this gate, where the caller was already allowed the key. - It fails closed, by failing. Nothing here rescues: an exception
out of the callback leaves the plug and becomes a
500, and is never turned into permission. A callback that cannot reach its database stops the request rather than guessing at it — which is the only safe way round, and the reason to let it raise rather than answerfalseon an error it did not expect.
Serving other people's files
Uploads served from the application's own origin are a standing hazard:
a file the browser decides to render as HTML runs as the application.
So a response carrying bytes always has x-content-type-options: nosniff,
and only a short list of image types is served inline. Everything else —
including SVG, which is a document that can carry script — is sent as a
download, whatever it claims to be.
A redirect carries none of those headers, which is why one is only offered
for the types that would have been served inline anyway, and why a storage
implementing Coelho.Storage.redirect_url/3 is expected to honour the
:content_type it is given.