Skip to content
62 linesCodeBlameRaw
1/**
2 * Headers that keep bytes someone else wrote from running as a page.
3 *
4 * The package registries answer on the site's own origin, and what they
5 * serve (a POM, a nuspec, a manifest) is the publisher's. Every registry
6 * answer is told never to be sniffed, to run nothing and to load nothing,
7 * and one a browser would open as a document is a download instead. The
8 * clients the registries serve ignore all three headers.
9 */
10
11/** Nothing loads and nothing runs: the policy for bytes that are only ever data. */
12export const NOTHING_RUNS = "default-src 'none'; sandbox";
13
14/**
15 * Types a browser shows as data, never as a page: JSON, plain text,
16 * archives and checked images. Anything else (HTML, SVG, any XML, an
17 * unknown or missing type) could become a page, so it is a download.
18 */
19const SHOWN_AS_DATA = [
20 /^text\/plain$/,
21 /^application\/json$/,
22 /^application\/[a-z0-9.+-]+\+json$/,
23 /^application\/(?:octet-stream|gzip|x-gzip|zip|x-tar|java-archive|pgp-signature)$/,
24 /^application\/vnd\.[a-z0-9.+-]+$/,
25 /^image\/(?:png|jpeg|gif|webp|avif)$/,
26];
27
28/** The media type alone: lowercase, without parameters. */
29export function mediaType(contentType: string | null | undefined): string {
30 return (contentType ?? "").split(";")[0]!.trim().toLowerCase();
31}
32
33/** Whether a browser could open a body of this type as a document. */
34export function opensAsDocument(contentType: string | null | undefined): boolean {
35 const type = mediaType(contentType);
36 if (/\+xml$|\/xml$|xml-|html|svg|xsl/.test(type)) return true;
37 return !SHOWN_AS_DATA.some((pattern) => pattern.test(type));
38}
39
40/**
41 * The headers a registry answer gains: no sniffing, a policy that runs
42 * nothing, and, for a type a browser would open as a document, an
43 * attachment. A disposition the service already set is kept.
44 */
45export function hardenRegistryHeaders(headers: Headers): void {
46 headers.set("x-content-type-options", "nosniff");
47 headers.set("content-security-policy", NOTHING_RUNS);
48 if (!headers.has("content-disposition") && opensAsDocument(headers.get("content-type"))) {
49 headers.set("content-disposition", "attachment");
50 }
51}
52
53/**
54 * A `Content-Disposition` for a download named `filename`: an ASCII
55 * fallback with anything unsafe replaced, and the exact name as
56 * RFC 6266's `filename*`.
57 */
58export function contentDisposition(filename: string): string {
59 const fallback = filename.replace(/[^\x20-\x7e]|["\\%;]/g, "_") || "download";
60 const exact = encodeURIComponent(filename.replace(/[\x00-\x1f\x7f]/g, "_")).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
61 return `attachment; filename="${fallback}"; filename*=UTF-8''${exact}`;
62}