Coelho.Plug.Attachments (coelho v0.3.1)

Copy Markdown View Source

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 — a Coelho.Storage, or an {m, f, a} returning one, required. The {m, f, a} form matters in an endpoint, where init/1 may 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; see Coelho.Attachments.signed_url/4
  • :metadata — an {m, f, a} called with the key, returning %{content_type: …, filename: …} or nil. Coelho does not hold a repo, so this is how the row reaches the response
  • :authorize — an {m, f, a} or a fun/2, called with the connection and the key once the signature has checked out. Return :ok or true to 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
end

The 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 :metadata is not called for a request that will be refused, and Coelho.Storage.redirect_url/3 is 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. Not 404: the two answers are deliberately identical, because telling "this is not yours" apart from "this does not exist" tells the caller it exists. A 404 only 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 answer false on 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.