Registry answers run nothing in a browser: nosniff, a sandbox policy, and publisher documents as downloads
The package registries answer on g1t.sh, and a Maven POM or a NuGet nuspec is its publisher's XML, served inline as application/xml with no policy: an XHTML-namespace document opened in a browser could run script on the site's origin. - Web Worker: every answer handed over to the packages service (and the redirect for a renamed workspace) gets X-Content-Type-Options: nosniff and Content-Security-Policy: default-src 'none'; sandbox, and a type a browser would open as a document (HTML, SVG, any XML, an unknown or missing type) gets Content-Disposition: attachment unless the service set one (app/lib/content-safety.ts). - Packages service: the same headers on every registry answer, so the service is safe on its own; the registry dispatch moves to Packages::serve_registry. - Package managers ignore all three headers; content types are unchanged. - Docs: packages guide, Downloads.
| 192 | 192 | tarball, a crate, a Maven artifact (not its POM or signatures), a NuGet | |
| 193 | 193 | `.nupkg`, a gem, and a Composer zip. | |
| 194 | 194 | ||
| 195 | + | A package's files are its publisher's, so the registries never let a | |
| 196 | + | browser run them. Every registry answer carries | |
| 197 | + | `X-Content-Type-Options: nosniff` and | |
| 198 | + | `Content-Security-Policy: default-src 'none'; sandbox`, and a file a | |
| 199 | + | browser would open as a page, such as a POM, a `.nuspec` or anything else | |
| 200 | + | in XML, HTML or SVG, comes with `Content-Disposition: attachment`, so it | |
| 201 | + | downloads instead. Package managers ignore these headers. | |
| 202 | + | ||
| 195 | 203 | ## The API | |
| 196 | 204 | ||
| 197 | 205 | The [REST API](/reference/api/) and the `package` tool of the |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { attachment, hardenRegistryHeaders, NOTHING_RUNS, opensAsDocument } from "./content-safety.ts"; | |
| 5 | + | ||
| 6 | + | test("publisher documents a browser would open are downloads", () => { | |
| 7 | + | for (const type of [ | |
| 8 | + | "application/xml", | |
| 9 | + | "text/xml; charset=utf-8", | |
| 10 | + | "application/xhtml+xml", | |
| 11 | + | "text/html", | |
| 12 | + | "image/svg+xml", | |
| 13 | + | "application/vnd.example+xml", | |
| 14 | + | "text/javascript", | |
| 15 | + | "", | |
| 16 | + | null, | |
| 17 | + | ]) { | |
| 18 | + | assert.equal(opensAsDocument(type), true, String(type)); | |
| 19 | + | } | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | test("data types stay inline", () => { | |
| 23 | + | for (const type of [ | |
| 24 | + | "application/json", | |
| 25 | + | "text/plain; charset=utf-8", | |
| 26 | + | "application/vnd.oci.image.manifest.v1+json", | |
| 27 | + | "application/vnd.npm.install-v1+json", | |
| 28 | + | "application/octet-stream", | |
| 29 | + | "application/java-archive", | |
| 30 | + | "application/gzip", | |
| 31 | + | "image/png", | |
| 32 | + | ]) { | |
| 33 | + | assert.equal(opensAsDocument(type), false, type); | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | test("every registry answer runs nothing and is never sniffed", () => { | |
| 38 | + | const pom = new Headers({ "content-type": "application/xml" }); | |
| 39 | + | hardenRegistryHeaders(pom); | |
| 40 | + | assert.equal(pom.get("x-content-type-options"), "nosniff"); | |
| 41 | + | assert.equal(pom.get("content-security-policy"), NOTHING_RUNS); | |
| 42 | + | assert.equal(pom.get("content-disposition"), "attachment"); | |
| 43 | + | ||
| 44 | + | const json = new Headers({ "content-type": "application/json" }); | |
| 45 | + | hardenRegistryHeaders(json); | |
| 46 | + | assert.equal(json.get("content-security-policy"), NOTHING_RUNS); | |
| 47 | + | assert.equal(json.get("content-disposition"), null); | |
| 48 | + | ||
| 49 | + | const named = new Headers({ "content-type": "text/html", "content-disposition": 'attachment; filename="a.html"' }); | |
| 50 | + | hardenRegistryHeaders(named); | |
| 51 | + | assert.equal(named.get("content-disposition"), 'attachment; filename="a.html"'); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("download names keep quotes and control characters out of the header", () => { | |
| 55 | + | assert.equal(attachment("web-main.zip"), `attachment; filename="web-main.zip"; filename*=UTF-8''web-main.zip`); | |
| 56 | + | const sly = attachment('web-a"b\r\nSet-Cookie: x.zip'); | |
| 57 | + | assert.ok(!/[\r\n]/.test(sly)); | |
| 58 | + | assert.match(sly, /^attachment; filename="web-a_b__Set-Cookie: x.zip"; filename\*=UTF-8''web-a%22b__Set-Cookie%3A%20x.zip$/); | |
| 59 | + | assert.match(attachment("café.zip"), /filename="caf_.zip"; filename\*=UTF-8''caf%C3%A9.zip$/); | |
| 60 | + | }); |
| 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 attachment(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 | + | } |
| 2 | 2 | ||
| 3 | 3 | import { identityClient, isNamespaceShaped } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | + | import { hardenRegistryHeaders } from "../app/lib/content-safety"; | |
| 5 | 6 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 6 | 7 | import { goImport } from "../app/lib/go-get"; | |
| 7 | 8 | import { repositoryOfPage, stillPublic } from "../app/lib/public-cache"; | |
| ⋯ | |||
| 167 | 168 | * follows them itself. One that found nothing under a workspace's old name | |
| 168 | 169 | * or an alias staff set (`g1t` for `flagon-io`) is sent to the same path | |
| 169 | 170 | * under the workspace's name: only the not-found answer pays for the lookup. | |
| 171 | + | * Every answer runs nothing in a browser (app/lib/content-safety.ts): what | |
| 172 | + | * a registry serves is its publisher's, on this origin. | |
| 170 | 173 | */ | |
| 171 | 174 | async function proxyPackages(env: Env, request: Request): Promise<Response> { | |
| 172 | 175 | const started = Date.now(); | |
| 173 | 176 | const answer = await env.PACKAGES.fetch(new Request(request, { redirect: "manual" })); | |
| 174 | 177 | const moved = answer.status === 404 ? await registryMoved(env, request) : null; | |
| 175 | 178 | const response = moved ?? new Response(answer.body, answer); | |
| 179 | + | hardenRegistryHeaders(response.headers); | |
| 176 | 180 | response.headers.append("server-timing", `packages;dur=${Date.now() - started}`); | |
| 177 | 181 | return response; | |
| 178 | 182 | } | |
| 852 | 852 | } | |
| 853 | 853 | } | |
| 854 | 854 | ||
| 855 | − | #[event(fetch)] | |
| 856 | − | async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> { | |
| 857 | − | let packages = Packages::from_env(&env)?; | |
| 858 | − | let Some(method) = rpc_method(&request) else { | |
| 859 | − | if request.path().starts_with("/-/npm/") || request.path() == "/-/npm" { | |
| 860 | − | return packages.npm(request, &ctx).await; | |
| 855 | + | impl Packages { | |
| 856 | + | /// A registry request, to the registry its path names. | |
| 857 | + | async fn serve_registry(&self, request: Request, ctx: &Context) -> Result<Response> { | |
| 858 | + | let path = request.path(); | |
| 859 | + | if path.starts_with("/-/npm/") || path == "/-/npm" { | |
| 860 | + | return self.npm(request, ctx).await; | |
| 861 | 861 | } | |
| 862 | − | if request.path().starts_with("/-/composer/") { | |
| 863 | − | return packages.composer(request, &ctx).await; | |
| 862 | + | if path.starts_with("/-/composer/") { | |
| 863 | + | return self.composer(request, ctx).await; | |
| 864 | 864 | } | |
| 865 | − | if request.path().starts_with("/-/cargo/") { | |
| 866 | − | return packages.cargo(request, &ctx).await; | |
| 865 | + | if path.starts_with("/-/cargo/") { | |
| 866 | + | return self.cargo(request, ctx).await; | |
| 867 | 867 | } | |
| 868 | − | if request.path().starts_with("/-/maven/") { | |
| 869 | − | return packages.maven(request, &ctx).await; | |
| 868 | + | if path.starts_with("/-/maven/") { | |
| 869 | + | return self.maven(request, ctx).await; | |
| 870 | 870 | } | |
| 871 | − | if request.path().starts_with("/-/nuget/") { | |
| 872 | − | return packages.nuget(request, &ctx).await; | |
| 871 | + | if path.starts_with("/-/nuget/") { | |
| 872 | + | return self.nuget(request, ctx).await; | |
| 873 | 873 | } | |
| 874 | − | if request.path().starts_with("/-/rubygems/") { | |
| 875 | − | return packages.rubygems(request, &ctx).await; | |
| 874 | + | if path.starts_with("/-/rubygems/") { | |
| 875 | + | return self.rubygems(request, ctx).await; | |
| 876 | 876 | } | |
| 877 | − | return packages.registry(request, &ctx).await; | |
| 877 | + | self.registry(request, ctx).await | |
| 878 | + | } | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | /// The policy for registry answers: what they serve is a publisher's bytes, | |
| 882 | + | /// on the site's origin, so nothing in them may load or run. | |
| 883 | + | const NOTHING_RUNS: &str = "default-src 'none'; sandbox"; | |
| 884 | + | ||
| 885 | + | /// Whether a browser could open a body of this type as a page: HTML, SVG, | |
| 886 | + | /// any XML, or a type it does not know as data. Such a body is a download. | |
| 887 | + | fn opens_as_document(content_type: Option<&str>) -> bool { | |
| 888 | + | let kind = content_type.unwrap_or("").split(';').next().unwrap_or("").trim().to_ascii_lowercase(); | |
| 889 | + | if kind.ends_with("+xml") || kind.ends_with("/xml") || kind.contains("html") || kind.contains("svg") || kind.contains("xsl") { | |
| 890 | + | return true; | |
| 891 | + | } | |
| 892 | + | let data = kind == "text/plain" | |
| 893 | + | || kind == "application/json" | |
| 894 | + | || (kind.starts_with("application/") && kind.ends_with("+json")) | |
| 895 | + | || matches!( | |
| 896 | + | kind.as_str(), | |
| 897 | + | "application/octet-stream" | |
| 898 | + | | "application/gzip" | |
| 899 | + | | "application/x-gzip" | |
| 900 | + | | "application/zip" | |
| 901 | + | | "application/x-tar" | |
| 902 | + | | "application/java-archive" | |
| 903 | + | | "application/pgp-signature" | |
| 904 | + | | "image/png" | |
| 905 | + | | "image/jpeg" | |
| 906 | + | | "image/gif" | |
| 907 | + | | "image/webp" | |
| 908 | + | | "image/avif" | |
| 909 | + | ) | |
| 910 | + | || kind.starts_with("application/vnd."); | |
| 911 | + | !data | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | /// The headers every registry answer carries: no sniffing, nothing runs, | |
| 915 | + | /// and a type a browser would open is an attachment. The site's Worker | |
| 916 | + | /// sets the same (apps/web/app/lib/content-safety.ts); this keeps the | |
| 917 | + | /// service safe on its own. | |
| 918 | + | fn harden(mut response: Response) -> Result<Response> { | |
| 919 | + | let headers = response.headers_mut(); | |
| 920 | + | headers.set("x-content-type-options", "nosniff")?; | |
| 921 | + | headers.set("content-security-policy", NOTHING_RUNS)?; | |
| 922 | + | if headers.get("content-disposition")?.is_none() && opens_as_document(headers.get("content-type")?.as_deref()) { | |
| 923 | + | headers.set("content-disposition", "attachment")?; | |
| 924 | + | } | |
| 925 | + | Ok(response) | |
| 926 | + | } | |
| 927 | + | ||
| 928 | + | #[event(fetch)] | |
| 929 | + | async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> { | |
| 930 | + | let packages = Packages::from_env(&env)?; | |
| 931 | + | let Some(method) = rpc_method(&request) else { | |
| 932 | + | let answer = packages.serve_registry(request, &ctx).await?; | |
| 933 | + | return harden(answer); | |
| 878 | 934 | }; | |
| 879 | 935 | let body: serde_json::Value = request.json().await?; | |
| 880 | 936 | match method.as_str() { | |
| ⋯ | |||
| 955 | 1011 | use super::*; | |
| 956 | 1012 | use serde_json::json; | |
| 957 | 1013 | ||
| 1014 | + | #[test] | |
| 1015 | + | fn publisher_documents_are_downloads() { | |
| 1016 | + | for kind in ["application/xml", "text/xml; charset=utf-8", "application/xhtml+xml", "text/html", "image/svg+xml", "application/vnd.foo+xml", "", "text/javascript"] { | |
| 1017 | + | assert!(opens_as_document(Some(kind)), "{kind}"); | |
| 1018 | + | } | |
| 1019 | + | assert!(opens_as_document(None)); | |
| 1020 | + | for kind in [ | |
| 1021 | + | "application/json", | |
| 1022 | + | "text/plain; charset=utf-8", | |
| 1023 | + | "application/vnd.oci.image.manifest.v1+json", | |
| 1024 | + | "application/vnd.npm.install-v1+json", | |
| 1025 | + | "application/octet-stream", | |
| 1026 | + | "application/java-archive", | |
| 1027 | + | "application/gzip", | |
| 1028 | + | "application/pgp-signature", | |
| 1029 | + | ] { | |
| 1030 | + | assert!(!opens_as_document(Some(kind)), "{kind}"); | |
| 1031 | + | } | |
| 1032 | + | } | |
| 1033 | + | ||
| 958 | 1034 | fn event(kind: &str, data: serde_json::Value) -> Event { | |
| 959 | 1035 | Event { | |
| 960 | 1036 | id: "evt_1".into(), | |