AudioProxy.Source.Local (audio_proxy v0.7.0)

Copy Markdown View Source

The local:// source type: files under one configured directory.

local://previews/track.wav names a path relative to AP_LOCAL_ROOT. The root is deployment configuration — a bind mount, a volume, a directory on a laptop — and it deliberately does not appear in the source's identity, so the same relative path is the same variant whatever a deployment mounted it at.

When AP_LOCAL_ROOT is unset, local sources are disabled: nothing is mounted, so nothing is served. The root is the allowlist for disk, which is why this type has no allowlist of its own.

Confinement

Every hostile path is refused by authorize/1, and refused the same way — {:error, :not_allowed}, which the HTTP layer renders as 404. Same status as a missing file, on purpose: a distinct status would turn the root into an existence oracle for the filesystem around it.

A hostile filesystem object is a different question, and one authorize/1 deliberately does not answer: a FIFO or a directory under the root is a perfectly legitimate path. Those are refused by stat/1 and ffmpeg_input/1, which are the two functions that actually touch the file — see The seam.

The order matters more than any single check:

  1. AudioProxy.Source has already decoded the source exactly once and refused control-class code points, NUL among them. A confinement check on a half-decoded string proves nothing, so it does not run on one.
  2. Path.safe_relative/2 rejects absolute paths and any .. that climbs out of the root, and normalizes . and interior .. away.
  3. The result is joined to the root and resolved link by link, because safe_relative does not follow symlinks — a previews symlinked at /etc is a perfectly safe relative path.
  4. The resolved path must still sit under the resolved root. This is the invariant, and the one the property test pins: whatever is accepted, its final path has the root as a prefix.

Nothing is normalized-and-continued. A path that fails any step is refused, not repaired. Step 2 is stricter than step 4 in one respect worth knowing about: OTP treats any absolute symlink target as unsafe, so a link inside the root that spells its target absolutely is refused even though the target is somewhere this type would happily serve. Relative links inside the root work; absolute ones want a root pointed at the resolved location instead. parse/1 takes almost no part in this. It collapses empty and . segments — lexical work that is safe under any filesystem, and stops one file from wearing several cache keys — and otherwise hands the path through. It does not touch .., and it leaves a leading / in place, so an absolute path stays absolute and gets refused rather than quietly reinterpreted. Everything that needs to know what is on disk stays in authorize/1, in one place, run on every source including one a caller built by hand.

Two bounds are enforced before any of it: at most 64 path components and 1024 bytes. Path.safe_relative/2 is superlinear in component count — a thousand components costs seconds of scheduler time on one process — so the cap is a denial-of-service control.

The seam

stat/1 reads size and mtime and hashes them into ETag material. ffmpeg_input/1 returns the resolved absolute path. Both re-run confinement rather than trusting an earlier authorize/1, and both refuse anything that is not a regular file: handing ffmpeg a FIFO is how you get a process that blocks forever on a read that never completes, holding a render slot until the timeout kills it.

What confinement does not cover

Confinement is defined over paths, and two things escape that definition. Both are deployment assumptions, not code defects, and both are documented in the README:

  • Hardlinks. A hardlink inside the root pointing at an inode outside it is indistinguishable from an ordinary file to any path-based check.
  • Time of check to time of use. ffmpeg_input/1 returns a path, and ffmpeg opens it a moment later. Anything that can rewrite the root in that window can swap the file for a symlink. Closing this needs the render pipeline to pass an already-open descriptor, which is tracked with add-render-endpoint.

Both require write access to the root, which is the assumption to hold: mount it read-only, and do not let untrusted code write into it.

Summary

Types

t()

A local source: a path relative to AP_LOCAL_ROOT, as written.

Functions

A short human-readable sentence for one of this type's own reasons.

Types

t()

@type t() :: {:local, String.t()}

A local source: a path relative to AP_LOCAL_ROOT, as written.

Functions

message(atom)

@spec message(atom()) :: String.t()

A short human-readable sentence for one of this type's own reasons.