Skip to content

Commit

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.

syntaqxcommitted Parent72b183bBrowse files
5 files+227−170/5 viewed
+8−0
192192 tarball, a crate, a Maven artifact (not its POM or signatures), a NuGet
193193 `.nupkg`, a gem, and a Composer zip.
194194
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+
195203 ## The API
196204
197205 The [REST API](/reference/api/) and the `package` tool of the
+60−0
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+});
+62−0
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+}
+4−0
22
33 import { identityClient, isNamespaceShaped } from "@g1t/contracts";
44
5+import { hardenRegistryHeaders } from "../app/lib/content-safety";
56 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
67 import { goImport } from "../app/lib/go-get";
78 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
167168 * follows them itself. One that found nothing under a workspace's old name
168169 * or an alias staff set (`g1t` for `flagon-io`) is sent to the same path
169170 * 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.
170173 */
171174 async function proxyPackages(env: Env, request: Request): Promise<Response> {
172175 const started = Date.now();
173176 const answer = await env.PACKAGES.fetch(new Request(request, { redirect: "manual" }));
174177 const moved = answer.status === 404 ? await registryMoved(env, request) : null;
175178 const response = moved ?? new Response(answer.body, answer);
179+ hardenRegistryHeaders(response.headers);
176180 response.headers.append("server-timing", `packages;dur=${Date.now() - started}`);
177181 return response;
178182 }
+93−17
852852 }
853853 }
854854
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;
861861 }
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;
864864 }
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;
867867 }
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;
870870 }
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;
873873 }
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;
876876 }
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);
878934 };
879935 let body: serde_json::Value = request.json().await?;
880936 match method.as_str() {
9551011 use super::*;
9561012 use serde_json::json;
9571013
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+
9581034 fn event(kind: &str, data: serde_json::Value) -> Event {
9591035 Event {
9601036 id: "evt_1".into(),