| 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. */ |
| 12 | export 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 | */ |
| 19 | const 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. */ |
| 29 | export 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. */ |
| 34 | export 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 | */ |
| 45 | export 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 | */ |
| 58 | export 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 | } |