AudioProxy.Source.Allowlist (audio_proxy v0.4.0)

Copy Markdown View Source

AP_SOURCE_ALLOWLIST: which buckets and hosts a remote source may name.

One comma-separated list answers for both remote source types, because the question is the same one — is this namespace ours? — and the answer differs only in how a namespace is spelled. AudioProxy.Source.S3 asks about a bucket, AudioProxy.Source.Https about a host, and neither owns the matcher.

Local sources are not gated here: AP_LOCAL_ROOT is the whole allowlist for disk (AudioProxy.Source.Local).

The default differs by type, and that is the design

An unset (empty) allowlist accepts S3 sources and rejects HTTPS ones. An S3 bucket the proxy has no credentials for is unreadable whatever this list says, so the credentials are already a gate; an HTTPS URL has no such backstop, and an ungated one is a server-side request forgery primitive pointed at whatever the container can reach. Deny-by-default is the only safe posture for a fetch, so HTTPS sources need the operator to name a host before any of them work.

The wildcards are asymmetric, and the asymmetry is the security property

previews-*           bucket: trailing-`*` prefix glob
*.media.example      host:   leading-`*.` suffix glob, label-anchored
*                    either: everything
masters              either: exact

A bucket namespace belongs to the operator: nobody else can create previews-eu in their account, so a prefix glob hands out nothing. A host namespace belongs to anyone with a registrar, which is why hosts get the mirror image. cdn.* is the footgun this forecloses — it reads as "our CDN" and would mean "any host starting cdn.", cdn.evil.com included. A * anywhere but its type's documented position matches nothing rather than matching loosely; a pattern that cannot be honoured exactly is not honoured approximately.

The host glob is anchored to a label boundary, so *.media.example admits media.example and cdn.media.example, and refuses media.example.evil.com — a suffix match on raw bytes would accept it.

Buckets match case-sensitively and hosts fold case, because that is what S3 and DNS respectively do.

An IP-literal host is matched bracketless: the pattern for https://[::1]/… is ::1, since that is the form URI parses out and the form AudioProxy.Source.Https normalizes to. The canonical URL shows the brackets, so this is worth knowing — the mismatch would otherwise fail closed and silently.

Hosts are matched as written, with no IDN or punycode conversion: an operator allowlisting an internationalized domain writes its punycode form.

Summary

Types

Which namespace a subject lives in, and therefore which glob applies.

Functions

Decides whether subject — an S3 bucket or an already-normalized host — is allowlisted.

Whether one pattern admits one subject — authorize/2 without the config read, which is what makes the grammar testable on its own.

Types

kind()

@type kind() :: :bucket | :host

Which namespace a subject lives in, and therefore which glob applies.

Functions

authorize(kind, subject)

@spec authorize(kind(), term()) :: :ok | {:error, :not_allowed}

Decides whether subject — an S3 bucket or an already-normalized host — is allowlisted.

Returns a verdict for any input, including one a caller built by hand: this is a security gate, and a gate that raises is a gate that can be turned into a 500. Anything that is not a non-empty binary is refused rather than interpreted.

matches?(arg1, pattern, subject)

@spec matches?(kind(), String.t(), String.t()) :: boolean()

Whether one pattern admits one subject — authorize/2 without the config read, which is what makes the grammar testable on its own.