signed-url-policy

A fail-safe guard that enforces safe defaults on url() and signedUploadUrl() - force a download disposition, cap expiry, and require a server-enforced upload size limit. Provider-agnostic, no native dependencies, no metadata.

The built-in signedUrlPolicy() plugin turns the security caveats the url() and signedUploadUrl() docs spell out into the default. It's a wrap plugin that rewrites the options of those two operations before the adapter signs - so it never throws of its own accord, never touches the body, and lets every other verb pass straight through. It has no native dependencies and works on any adapter.

import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { signedUrlPolicy } from "files-sdk/signed-url-policy";

const files = createFiles({
  adapter: s3({ bucket: "uploads" }),
  plugins: [
    signedUrlPolicy({
      maxExpiresIn: 15 * 60, // no URL lives longer than 15 minutes
      maxUploadSize: 10 * 1024 * 1024, // every signed upload caps at 10 MiB
    }),
  ],
});

await files.url("user-upload.html"); // forced `attachment`, expires in <= 15 min
await files.signedUploadUrl("avatar.png", { expiresIn: 3600 }); // <= 15 min, <= 10 MiB

What it enforces

Every option is independent. With none set the plugin still applies the headline default: url() forces an attachment disposition.

OptionApplies toWhat it does
dispositionurl()Force a download Content-Disposition. Defaults to "attachment".
maxExpiresInurl() + signedUploadUrl()Clamp the URL lifetime (in seconds) down to this ceiling.
maxUploadSizesignedUploadUrl()Guarantee a server-enforced maxSize (in bytes) is always present, capped here.

Disposition

Without a forced disposition, the browser uses the stored Content-Type to decide whether to render or download a URL's contents - so a user-uploaded .html (or a script-bearing SVG) executes inline at your bucket's origin. That's stored XSS in your domain's trust context, and it's exactly the warning the url() docs carry. The policy defaults disposition to "attachment" so those files download instead.

It only fills in or overrides an unsafe disposition. A call that already asks for an attachment is left untouched, so a caller's 'attachment; filename="report.pdf"' keeps its filename; a call asking for inline (or passing nothing) is forced to the policy value.

signedUrlPolicy(); // disposition defaults to "attachment"

await files.url("user.html"); // -> attachment
await files.url("user.html", { responseContentDisposition: "inline" }); // -> attachment (overridden)
await files.url("doc.pdf", {
  responseContentDisposition: 'attachment; filename="report.pdf"',
}); // -> kept as-is (already a download)

Pass a full 'attachment; filename="..."' string to set a default filename, or disposition: false to disable the guard entirely (you keep the expiry cap and lose the XSS protection).

On signing adapters, setting a Content-Disposition forces the signing path even when publicBaseUrl is configured - a permanent CDN URL has no signature to bind the override into. That's the documented, safe-by-default behavior: the security override wins. If you rely on permanent public URLs and don't want this, set disposition: false.

Expiry

maxExpiresIn caps the lifetime of both url() and signedUploadUrl(). A request for a longer TTL is clamped down; a shorter one is left as-is. To guarantee the ceiling, a url() with no expiresIn is pinned to the cap rather than left to the adapter's own (unknown) default - so set maxExpiresIn to the real ceiling you want, not higher than it.

signedUrlPolicy({ maxExpiresIn: 900 });

await files.url("a", { expiresIn: 86_400 }); // clamped to 900
await files.url("a", { expiresIn: 60 }); // left at 60
await files.url("a"); // pinned to 900

Upload size

When omitted, signedUploadUrl() falls back to a presigned PUT with no server-side size limit - anyone with the URL can upload an arbitrarily large file until it expires. Set maxUploadSize and the policy guarantees a maxSize is always present: injected when the caller omits it, clamped when it's over the cap.

signedUrlPolicy({ maxUploadSize: 10 * 1024 * 1024 });

await files.signedUploadUrl("a", { expiresIn: 60 }); // maxSize injected: 10 MiB
await files.signedUploadUrl("a", { expiresIn: 60, maxSize: 50_000_000 }); // clamped to 10 MiB
await files.signedUploadUrl("a", { expiresIn: 60, maxSize: 4096 }); // left at 4 KiB

Enforcing a maxSize makes supporting adapters mint a presigned POST form (with a content-length-range policy) instead of a PUT, and adapters whose direct-upload primitive can't bind a size limit fail closed - they throw rather than hand out an unbounded URL. A size policy therefore turns an unenforceable provider into a loud error instead of a silent hole, which is the point.

Ordering

Put signedUrlPolicy() first (outermost) so it sees the caller's original url() / signedUploadUrl() request before anything downstream, and so the options it rewrites reach the adapter that actually signs.

plugins: [signedUrlPolicy({ maxExpiresIn: 900 }), encryption(key)];

Things to keep in mind

  • It only guards the two URL-minting verbs. url() and signedUploadUrl() are rewritten; reads, writes, copy, move, and list pass straight through untouched.
  • It stores no metadata and transforms nothing on disk. A bucket behind this policy is indistinguishable from one without it - safe to enable or remove at any time.
  • It's a policy, not a scanner. It enforces safe-by-default headers and limits; it doesn't inspect the bytes a signed upload will receive. Pair it with validation() for direct uploads and contentType() if you don't trust client-declared types.
  • It changes nothing on adapters without the matching primitive. expiresIn is ignored by always-public adapters (e.g. Vercel Blob); on those the disposition override and size cap surface the same way a direct call would (returning unchanged, or throwing where the adapter has no primitive).

On this page