From 88add0909a91d06b61e2ca112e5c0a28b963bdb0 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Mon, 22 Jun 2026 17:23:48 +0200 Subject: [PATCH] fix(pnpr): require auth for resolver requests (#12577) pnpr's resolver endpoint could previously accept anonymous resolve jobs and start outbound dependency-resolution work before checking any caller identity. Mount auth middleware on /-/pnpr/v0/resolve so Basic/Bearer credentials from the HTTP Authorization header are verified before Axum buffers or parses the body. Reject duplicate or non-text Authorization headers rather than picking one. The resolver now loads configured auth state at startup, including resolver-only deployments, so configured htpasswd/token/SQL backends are honored. New-user registration defaults to disabled unless auth.htpasswd.max_users is explicitly configured, preventing anonymous callers from creating credentials on a fresh server. Update pnpm and pacquet resolver fixtures to provision and send pnpr credentials, and add regression coverage proving anonymous Git resolve requests do not trigger egress while authenticated resolver requests still work. --- pacquet/crates/cli/tests/pnpr_install.rs | 56 +++- .../crates/pnpr-client/tests/integration.rs | 15 +- .../integrated-benchmark/src/work_env.rs | 38 ++- .../src/work_env/tests.rs | 8 +- .../jest-config/with-registry/globalSetup.js | 16 +- pnpm11/pnpm/test/install/pnpmRegistry.ts | 38 ++- pnpr/crates/pnpr/config.yaml | 8 +- pnpr/crates/pnpr/src/auth.rs | 36 +-- .../pnpr/src/auth/libsql_backend/tests.rs | 7 + pnpr/crates/pnpr/src/auth/tests.rs | 2 +- pnpr/crates/pnpr/src/config/tests.rs | 3 +- pnpr/crates/pnpr/src/server.rs | 102 +++++-- pnpr/crates/pnpr/tests/auth_persistence.rs | 42 ++- pnpr/crates/pnpr/tests/auth_publish.rs | 6 +- pnpr/crates/pnpr/tests/auth_user_endpoints.rs | 28 +- pnpr/crates/pnpr/tests/batch_publish.rs | 3 +- pnpr/crates/pnpr/tests/journal_recovery.rs | 3 +- pnpr/crates/pnpr/tests/policy.rs | 5 +- pnpr/crates/pnpr/tests/s3_backend.rs | 3 +- pnpr/crates/pnpr/tests/server.rs | 259 +++++++++++++++++- 20 files changed, 579 insertions(+), 99 deletions(-) diff --git a/pacquet/crates/cli/tests/pnpr_install.rs b/pacquet/crates/cli/tests/pnpr_install.rs index 8278c15d60..387e6f05ed 100644 --- a/pacquet/crates/cli/tests/pnpr_install.rs +++ b/pacquet/crates/cli/tests/pnpr_install.rs @@ -13,6 +13,7 @@ use pacquet_testing_utils::{ bin::{AddMockedRegistry, CommandTempCwd}, fs::is_symlink_or_junction, }; +use pnpr::TokenBackend; use std::{ fs, net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, @@ -23,8 +24,8 @@ use std::{ }; /// Start an in-process pnpr with the fast-path endpoints on a detached -/// thread; returns its base URL. -fn start_pnpr() -> String { +/// thread; returns its base URL and a pre-seeded bearer token. +fn start_pnpr() -> (String, String) { // Persisted (not cleaned) because the detached server thread outlives // this function. let storage = tempfile::tempdir().expect("pnpr storage").keep(); @@ -32,6 +33,15 @@ fn start_pnpr() -> String { // tokio's `from_std` requires the listener to be non-blocking. listener.set_nonblocking(true).expect("set pnpr listener non-blocking"); let addr = listener.local_addr().expect("pnpr addr"); + let tokens_path = storage.join("tokens.db"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("token setup runtime"); + let token = runtime.block_on(async { + let tokens = pnpr::TokenStore::open(tokens_path.clone()).expect("open token store"); + tokens.issue("pacquet-test").await.expect("issue pnpr test token") + }); thread::Builder::new() .name("pnpr".to_string()) @@ -43,6 +53,7 @@ fn start_pnpr() -> String { runtime.block_on(async move { let mut config = pnpr::Config::proxy(addr, storage); config.public_url = format!("http://{addr}"); + config.auth.tokens.file = Some(tokens_path); let listener = tokio::net::TcpListener::from_std(listener).expect("tokio listener"); let _ = pnpr::serve_listener(config, listener).await; }); @@ -50,7 +61,16 @@ fn start_pnpr() -> String { .expect("spawn pnpr thread"); wait_until_ready(addr); - format!("http://{addr}/") + (format!("http://{addr}/"), token) +} + +fn configure_pnpr_auth(npmrc_path: &std::path::Path, pnpr_url: &str, token: &str) { + let authority = + pnpr_url.strip_prefix("http://").expect("test pnpr URL uses http").trim_end_matches('/'); + let current = fs::read_to_string(npmrc_path).expect("read .npmrc"); + let separator = if current.ends_with('\n') { "" } else { "\n" }; + fs::write(npmrc_path, format!("{current}{separator}//{authority}/:_authToken={token}\n")) + .expect("write pnpr auth to .npmrc"); } fn wait_until_ready(addr: SocketAddr) { @@ -71,9 +91,10 @@ fn pacquet_at(workspace: &Path) -> Command { fn install_via_pnpr_links_node_modules() { let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = CommandTempCwd::init().add_mocked_registry(); - let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info; + let AddMockedRegistry { npmrc_path, store_dir, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -81,7 +102,13 @@ fn install_via_pnpr_links_node_modules() { }); fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); - pacquet.with_arg("install").with_arg("--pnpr-server").with_arg(&pnpr_url).assert().success(); + pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) + .with_arg("install") + .with_arg("--pnpr-server") + .with_arg(&pnpr_url) + .assert() + .success(); let symlink_path = workspace.join("node_modules/@foo/no-deps"); assert!(is_symlink_or_junction(&symlink_path).unwrap(), "direct dep should be symlinked"); @@ -101,7 +128,8 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo CommandTempCwd::init().add_mocked_registry(); let AddMockedRegistry { npmrc_path, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -109,7 +137,13 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo }); fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); - pacquet.with_arg("install").with_arg("--pnpr-server").with_arg(&pnpr_url).assert().success(); + pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) + .with_arg("install") + .with_arg("--pnpr-server") + .with_arg(&pnpr_url) + .assert() + .success(); fs::remove_dir_all(workspace.join("node_modules")).expect("remove node_modules"); let mut verifier = mockito::Server::new(); @@ -162,9 +196,10 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo fn install_via_pnpr_lockfile_only_writes_lockfile_without_linking() { let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = CommandTempCwd::init().add_mocked_registry(); - let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info; + let AddMockedRegistry { npmrc_path, store_dir, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -173,6 +208,7 @@ fn install_via_pnpr_lockfile_only_writes_lockfile_without_linking() { fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) .with_arg("install") .with_arg("--pnpr-server") .with_arg(&pnpr_url) diff --git a/pacquet/crates/pnpr-client/tests/integration.rs b/pacquet/crates/pnpr-client/tests/integration.rs index fb14f24295..fc0decf10e 100644 --- a/pacquet/crates/pnpr-client/tests/integration.rs +++ b/pacquet/crates/pnpr-client/tests/integration.rs @@ -20,6 +20,9 @@ use pacquet_testing_utils::registry::TestRegistry; use tempfile::TempDir; use tokio::net::TcpListener; +/// Basic auth for `pnpr-client:password123`, registered by [`start_pnpr`]. +const PNPR_AUTHORIZATION: &str = "Basic cG5wci1jbGllbnQ6cGFzc3dvcmQxMjM="; + /// Start an in-process pnpr with the fast-path endpoints. Returns the /// base URL and the storage guard. async fn start_pnpr() -> (String, TempDir) { @@ -29,13 +32,16 @@ async fn start_pnpr() -> (String, TempDir) { let mut config = pnpr::Config::proxy(addr, storage.path().to_path_buf()); config.public_url = format!("http://{addr}"); + config.auth.htpasswd.max_users = pnpr::MaxUsers::Unlimited; tokio::spawn(async move { let _ = pnpr::serve_listener(config, listener).await; }); wait_until_ready(addr).await; - (format!("http://{addr}/"), storage) + let base_url = format!("http://{addr}/"); + let _ = register_token(&base_url, "pnpr-client").await; + (base_url, storage) } async fn wait_until_ready(addr: SocketAddr) { @@ -70,8 +76,9 @@ fn nerf_key(url: &str) -> String { if path.is_empty() { format!("//{authority}/") } else { format!("//{authority}/{path}/") } } -/// Register a user with the shared test registry and return its bearer -/// token, so a test can forward it as the caller's upstream credential. +/// Register a user with an npm-compatible registry and return its bearer +/// token. The pnpr fixture reuses the account through Basic auth; registry +/// tests forward the token as an upstream credential. async fn register_token(registry_url: &str, username: &str) -> String { let body = serde_json::json!({ "name": username, "password": "password123" }); let response = reqwest::Client::new() @@ -93,7 +100,7 @@ fn options(registry: &str, dependencies: BTreeMap) -> ResolveOpt registry: registry.to_string(), named_registries: BTreeMap::new(), auth_headers: BTreeMap::new(), - authorization: None, + authorization: Some(PNPR_AUTHORIZATION.to_string()), overrides: None, lockfile: None, frozen_lockfile: false, diff --git a/pacquet/tasks/integrated-benchmark/src/work_env.rs b/pacquet/tasks/integrated-benchmark/src/work_env.rs index 28a00eb0a1..21683fd9cc 100644 --- a/pacquet/tasks/integrated-benchmark/src/work_env.rs +++ b/pacquet/tasks/integrated-benchmark/src/work_env.rs @@ -18,7 +18,7 @@ use std::{ borrow::Cow, collections::HashMap, fmt::{self, Write as _}, - fs::{self, File}, + fs::{self, File, OpenOptions}, io::Write, net::{Ipv4Addr, SocketAddr, TcpStream}, path::{Path, PathBuf}, @@ -33,6 +33,9 @@ const BENCHMARK_DIAGNOSTICS_MD: &str = "BENCHMARK_DIAGNOSTICS.md"; const PNPR_DIRECT_RATIO_MAX: f64 = 1.05; const PNPR_SERVER_REGISTRY_ENV: &str = "PACQUET_BENCHMARK_PNPR_SERVER_REGISTRY"; const PNPR_TARBALL_REWRITE_FROM_ENV: &str = "PACQUET_BENCHMARK_PNPR_TARBALL_REWRITE_FROM"; +const PNPR_BENCHMARK_AUTH_PAIR_BASE64: &str = "cG5wci1iZW5jaG1hcms6cGFzc3dvcmQxMjM="; +const PNPR_BENCHMARK_HTPASSWD: &str = + "pnpr-benchmark:$2y$04$MpoezfOJSlhn9S4iiOJihue1IMZTZfYclKbajdz.Dt2pvAoBLNAay\n"; #[derive(Debug)] pub struct WorkEnv { @@ -501,6 +504,8 @@ impl WorkEnv { binary.is_file(), "pnpr binary not found at {binary:?} — the build step did not produce it", ); + let pnpr_storage = bench_dir.join("pnpr-storage"); + seed_pnpr_auth(&pnpr_storage); let port = pick_unused_port().expect("pick an unused port for the pnpr server"); eprintln!("Starting pnpr server for {id} on 127.0.0.1:{port}..."); @@ -512,7 +517,7 @@ impl WorkEnv { .arg("--listen") .arg(format!("127.0.0.1:{port}")) .arg("--storage") - .arg(bench_dir.join("pnpr-storage")) + .arg(&pnpr_storage) // The resolver resolves against the registry the client // sends, caching packuments in its own store. A long TTL keeps // those cached packuments authoritative across the run, the @@ -571,6 +576,7 @@ impl WorkEnv { // raw upstream tarball URLs even when the server resolves through a // latency proxy; the pacquet client rewrites this prefix to its // configured registry, which is `client_registry` from `.npmrc`. + append_pnpr_auth_to_npmrc(&bench_dir, &client_url); fs::write( bench_dir.join(".pnpr-env"), format!( @@ -1362,6 +1368,34 @@ fn create_npmrc(dir: &Path, registry: &str, scenario: BenchmarkScenario) { writeln!(file, "{}", scenario.npmrc_lockfile_setting()).unwrap(); } +fn seed_pnpr_auth(pnpr_storage: &Path) { + fs::create_dir_all(pnpr_storage).expect("create pnpr storage before seeding auth"); + fs::write(pnpr_storage.join("htpasswd"), PNPR_BENCHMARK_HTPASSWD) + .expect("seed pnpr benchmark htpasswd"); +} + +fn append_pnpr_auth_to_npmrc(dir: &Path, pnpr_server: &str) { + let path = dir.join(".npmrc"); + let mut file = + OpenOptions::new().append(true).open(&path).expect("open benchmark .npmrc for pnpr auth"); + writeln!( + file, + "{}:_auth={}", + pnpr_auth_config_key(pnpr_server), + PNPR_BENCHMARK_AUTH_PAIR_BASE64, + ) + .expect("append pnpr auth to benchmark .npmrc"); +} + +fn pnpr_auth_config_key(pnpr_server: &str) -> String { + let Some(without_scheme) = + pnpr_server.strip_prefix("http://").or_else(|| pnpr_server.strip_prefix("https://")) + else { + panic!("pnpr server URL must include a scheme: {pnpr_server}"); + }; + format!("//{}/", without_scheme.trim_end_matches('/')) +} + fn may_create_lockfile(dst_dir: &Path, scenario: BenchmarkScenario, src_dir: Option<&Path>) { let load_lockfile = || -> Cow<'_, str> { let Some(src_dir) = src_dir else { return Cow::Borrowed(LOCKFILE) }; diff --git a/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs b/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs index 7cde2143c7..a26ffd5a03 100644 --- a/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs +++ b/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs @@ -1,6 +1,6 @@ use super::{ BenchmarkScenario, HyperfineCommand, PhaseEvent, collect_pnpr_direct_ratios, - non_trivial_cold_batch, read_phase_events, render_diagnostics_markdown, + non_trivial_cold_batch, pnpr_auth_config_key, read_phase_events, render_diagnostics_markdown, requires_fresh_pnpr_cold_batch_metrics, summarize_phase_events, }; use std::{collections::HashMap, fs}; @@ -136,6 +136,12 @@ fn cold_batch_metrics_canary_targets_current_pnpr_revision() { assert!(!requires_fresh_pnpr_cold_batch_metrics("pacquet@HEAD")); } +#[test] +fn pnpr_auth_config_key_uses_npmrc_nerf_shape() { + assert_eq!(pnpr_auth_config_key("http://127.0.0.1:42509"), "//127.0.0.1:42509/"); + assert_eq!(pnpr_auth_config_key("http://localhost:4873/pnpr/"), "//localhost:4873/pnpr/"); +} + #[test] fn diagnostics_markdown_includes_create_virtual_store_line_item() { let markdown = render_diagnostics_markdown( diff --git a/pnpm11/__utils__/jest-config/with-registry/globalSetup.js b/pnpm11/__utils__/jest-config/with-registry/globalSetup.js index 478a69260b..4d340ed884 100644 --- a/pnpm11/__utils__/jest-config/with-registry/globalSetup.js +++ b/pnpm11/__utils__/jest-config/with-registry/globalSetup.js @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { scheduler } from 'node:timers/promises' @@ -26,12 +26,14 @@ export default async () => { const storage = mkdtempSync(path.join(tmpdir(), 'pnpm-registry-mock-storage-')) buildStorage(storage) process.env.PNPM_REGISTRY_MOCK_STORAGE = storage + const config = writeTestConfig(storage) const bin = resolvePnprBin() const server = spawn( bin, [ + '--config', config, '--listen', `127.0.0.1:${process.env.PNPM_REGISTRY_MOCK_PORT}`, '--storage', storage, '--public-url', `http://localhost:${process.env.PNPM_REGISTRY_MOCK_PORT}`, @@ -89,6 +91,18 @@ export default async () => { process.env.REGISTRY_MOCK_TOKEN = token } +function writeTestConfig (storage) { + const source = path.join(REPO_ROOT, 'pnpr', 'crates', 'pnpr', 'config.yaml') + const bundled = readFileSync(source, 'utf8') + const configured = bundled.replace('max_users: -1', 'max_users: 100') + if (configured === bundled) { + throw new Error('pnpr test config could not enable test-only registration') + } + const target = path.join(storage, 'config.yaml') + writeFileSync(target, configured) + return target +} + /** * Build registry storage from the in-repo fixtures into `out` using the * `pnpr-prepare` binary (built from the `pnpr-fixtures` crate). The same diff --git a/pnpm11/pnpm/test/install/pnpmRegistry.ts b/pnpm11/pnpm/test/install/pnpmRegistry.ts index 1823cc9334..8aa02b055d 100644 --- a/pnpm11/pnpm/test/install/pnpmRegistry.ts +++ b/pnpm11/pnpm/test/install/pnpmRegistry.ts @@ -55,8 +55,24 @@ afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())) }) +function configurePnprAuth (): void { + const token = process.env.REGISTRY_MOCK_TOKEN + if (!token) throw new Error('REGISTRY_MOCK_TOKEN is required for pnpr integration tests') + fs.appendFileSync('.npmrc', `//localhost:${serverPort}/:_authToken=${token}\n`) +} + +function prepareProject (manifest: Parameters[0]): void { + prepare(manifest) + configurePnprAuth() +} + +function prepareWorkspace (packages: Parameters[0]): void { + preparePackages(packages) + configurePnprAuth() +} + test('pnpm install uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', }, @@ -79,7 +95,7 @@ test('pnpm install uses pnpr server when configured', async () => { }) test('pnpm install resolves optionalDependencies via the pnpr server', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', }, @@ -102,7 +118,7 @@ test('pnpm install resolves optionalDependencies via the pnpr server', async () }) test('a second resolution forwards the existing lockfile to the pnpr server', async () => { - prepare({}) + prepareProject({}) // First add creates the lockfile. await execPnpm(['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]) @@ -120,7 +136,7 @@ test('a second resolution forwards the existing lockfile to the pnpr server', as }) test('pnpm add uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-negative': '1.0.0', }, @@ -146,7 +162,7 @@ test('pnpm add uses pnpr server when configured', async () => { }) test('pnpm remove uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', 'is-negative': '1.0.0', @@ -171,7 +187,7 @@ test('pnpm remove uses pnpr server when configured', async () => { }) test('pnpm add without a version uses the pnpr server and writes the save-prefix spec from the lockfile', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -190,7 +206,7 @@ test('pnpm add without a version uses the pnpr server and writes the save-prefix }) test('pnpm add -D uses pnpr server and targets devDependencies', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -210,7 +226,7 @@ test('pnpm add -D uses pnpr server and targets devDependencies', async () => { }) test('pnpm add with multiple selectors uses pnpr server', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -228,7 +244,7 @@ test('pnpm add with multiple selectors uses pnpr server', async () => { }) test('pnpm --filter remove inside a workspace uses pnpr server', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', @@ -268,7 +284,7 @@ test('pnpm --filter remove inside a workspace uses pnpr server', async () => { }) test('pnpm add inside a workspace project uses pnpr server', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', @@ -303,7 +319,7 @@ test('pnpm add inside a workspace project uses pnpr server', async () => { }) test('pnpm install with pnpr server works in a workspace with multiple projects', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', diff --git a/pnpr/crates/pnpr/config.yaml b/pnpr/crates/pnpr/config.yaml index 2f9fb117c9..1b1bcb4b31 100644 --- a/pnpr/crates/pnpr/config.yaml +++ b/pnpr/crates/pnpr/config.yaml @@ -52,11 +52,9 @@ secret: pnpm-registry-mock-secret-key-32 auth: htpasswd: file: ./htpasswd - # Maximum number of users allowed to self-register. Registration is - # opt-in: it stays disabled unless this is set to a positive number - # (set it to -1 to keep it disabled explicitly). This mock registry - # opts in so the test suite can create accounts. - max_users: 1000 + # Maximum amount of users allowed to register. The safe default is -1 + # (registration disabled); set a non-negative cap to allow registration. + max_users: -1 plugins: ../node_modules diff --git a/pnpr/crates/pnpr/src/auth.rs b/pnpr/crates/pnpr/src/auth.rs index e41c619d6a..8f7f93616f 100644 --- a/pnpr/crates/pnpr/src/auth.rs +++ b/pnpr/crates/pnpr/src/auth.rs @@ -131,16 +131,18 @@ impl std::fmt::Debug for AuthState { } impl AuthState { - /// All-in-memory auth state for surfaces that never expose - /// registration: a resolver-only deployment (registry disabled, so - /// the adduser route is not mounted) and tests that don't care - /// about persistence. Registration is left uncapped here because no - /// untrusted caller can reach it; the production registration path - /// goes through [`Self::load`], which honors `auth.htpasswd.max_users`. + /// All-in-memory auth state with open registration. Used by tests + /// and registry-mock-compatible programmatic routers. #[must_use] pub fn in_memory() -> Self { + Self::in_memory_with_max_users(MaxUsers::Unlimited) + } + + /// All-in-memory auth state that enforces the resolved registration cap. + #[must_use] + pub fn in_memory_with_max_users(max_users: MaxUsers) -> Self { Self { - users: Arc::new(UserStore::in_memory(MaxUsers::Unlimited)), + users: Arc::new(UserStore::in_memory_with_max_users(max_users)), tokens: Arc::new(TokenStore::in_memory()), } } @@ -203,7 +205,7 @@ impl AuthState { } let users: Arc = match auth.htpasswd.file.clone() { Some(path) => Arc::new(UserStore::open(path, auth.htpasswd.max_users)?), - None => Arc::new(UserStore::in_memory(auth.htpasswd.max_users)), + None => Arc::new(UserStore::in_memory_with_max_users(auth.htpasswd.max_users)), }; let tokens: Arc = match auth.tokens.file.clone() { Some(path) => Arc::new(TokenStore::open(path)?), @@ -314,14 +316,16 @@ pub struct UserStore { } impl UserStore { - /// In-memory store with no on-disk persistence. Used when - /// `auth.htpasswd.file` is unset and by the existing - /// `@pnpm/registry-mock` integration where every restart is a - /// fresh process. The registration cap is honored here just as it - /// is for the file-backed store, so an unset `auth.htpasswd.file` - /// does not silently re-open sign-ups that `max_users` denied. + /// In-memory store with no on-disk persistence and open registration. + /// Used by registry-mock-compatible programmatic routers. #[must_use] - pub fn in_memory(max_users: MaxUsers) -> Self { + pub fn in_memory() -> Self { + Self::in_memory_with_max_users(MaxUsers::Unlimited) + } + + /// In-memory store that enforces the resolved registration cap. + #[must_use] + pub fn in_memory_with_max_users(max_users: MaxUsers) -> Self { Self { users: Mutex::new(HashMap::new()), path: None, @@ -624,7 +628,7 @@ impl Default for TokenStore { impl Default for UserStore { fn default() -> Self { - Self::in_memory(MaxUsers::Unlimited) + Self::in_memory() } } diff --git a/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs b/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs index c1dc73f66e..cb0c8eb8e3 100644 --- a/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs +++ b/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs @@ -31,6 +31,13 @@ async fn add_or_login_rejects_existing_user_with_wrong_password() { assert_eq!(err.status_code(), axum::http::StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn max_users_disabled_rejects_registration() { + let backend = local_backend(MaxUsers::Disabled).await; + let err = backend.add_or_login("alice", "x").await.unwrap_err(); + assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN); +} + #[tokio::test] async fn add_or_login_rejects_invalid_username_before_insert() { let backend = local_backend(MaxUsers::Unlimited).await; diff --git a/pnpr/crates/pnpr/src/auth/tests.rs b/pnpr/crates/pnpr/src/auth/tests.rs index 982b0e5a38..0f9fb49f51 100644 --- a/pnpr/crates/pnpr/src/auth/tests.rs +++ b/pnpr/crates/pnpr/src/auth/tests.rs @@ -240,7 +240,7 @@ async fn max_users_minus_one_disables_registration() { async fn in_memory_store_honors_the_registration_cap() { // An unset `auth.htpasswd.file` must not re-open sign-ups that the // configured cap denied: the in-memory store enforces it too. - let store = UserStore::in_memory(MaxUsers::Disabled); + let store = UserStore::in_memory_with_max_users(MaxUsers::Disabled); let err = store.add_or_login("alice", "secret").await.unwrap_err(); assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN); } diff --git a/pnpr/crates/pnpr/src/config/tests.rs b/pnpr/crates/pnpr/src/config/tests.rs index 07d4ca5d65..e478a11490 100644 --- a/pnpr/crates/pnpr/src/config/tests.rs +++ b/pnpr/crates/pnpr/src/config/tests.rs @@ -443,6 +443,7 @@ fn from_default_yaml_parses_bundled_file() { let config = Config::from_default_yaml(Path::new("/tmp"), listen(), None); assert!(config.uplinks.contains_key("npmjs")); assert_eq!(config.uplinks["npmjs"].url, "https://registry.npmjs.org/"); + assert_eq!(config.auth.htpasswd.max_users, super::MaxUsers::Disabled); // The bundled file routes the catch-all through npmjs. let (name, _) = config.resolve_uplink("lodash").expect("** -> npmjs in defaults"); assert_eq!(name, "npmjs"); @@ -931,7 +932,7 @@ packages: {} } #[test] -fn auth_block_absent_keeps_in_memory_defaults() { +fn auth_block_absent_disables_registration_by_default() { let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n"; let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); assert!(config.auth.htpasswd.file.is_none()); diff --git a/pnpr/crates/pnpr/src/server.rs b/pnpr/crates/pnpr/src/server.rs index 2037cbdbdc..915d23348e 100644 --- a/pnpr/crates/pnpr/src/server.rs +++ b/pnpr/crates/pnpr/src/server.rs @@ -24,7 +24,7 @@ use axum::{ connect_info::Connected, }, http::{HeaderMap, Method, StatusCode, header, request::Parts}, - middleware::Next, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::{any, delete, get, post, put}, serve::IncomingStream, @@ -167,14 +167,16 @@ impl PackageLocks { /// router level, so we take both via one handler that branches on /// the `@` prefix and the literal-`-` segment. pub fn router(config: Config) -> Router { - router_with_auth(config, AuthState::in_memory()) + let max_users = config.auth.htpasswd.max_users; + router_with_auth(config, AuthState::in_memory_with_max_users(max_users)) } /// Fallible counterpart to [`router`]: surfaces a missing/invalid OSV /// database (when `osv.enabled`) as an error instead of panicking, for /// embedders that build the router directly rather than via [`serve`]. pub fn try_router(config: Config) -> crate::error::Result { - try_router_with_auth(config, AuthState::in_memory()) + let max_users = config.auth.htpasswd.max_users; + try_router_with_auth(config, AuthState::in_memory_with_max_users(max_users)) } /// Like [`router`] but with a caller-supplied [`AuthState`]. Used @@ -212,17 +214,14 @@ fn load_active_osv_index( } } -/// Run the registry surface's startup side effects and load its auth -/// backends — but only when the registry is enabled. A resolver-only -/// deployment skips publish-journal recovery and on-disk auth entirely, -/// so it stays stateless: it creates no credential files and reads -/// nothing from the store, and so can run on a read-only or ephemeral -/// host. The resolver routes never consult auth. +/// Run startup side effects and load auth backends for surfaces that +/// consult caller identity. The registry needs publish-journal recovery; +/// both the registry and resolver need auth because resolver requests +/// control outbound dependency-resolution work. async fn load_startup_auth(config: &Config) -> crate::error::Result { - if !config.registry.enabled { - return Ok(AuthState::in_memory()); + if config.registry.enabled { + crate::journal::recover_publish_journal(config).await?; } - crate::journal::recover_publish_journal(config).await?; AuthState::load(&config.auth, &config.backend).await } @@ -282,8 +281,20 @@ fn router_with_auth_and_osv( if resolver_enabled { router = router .route("/-/pnpr", get(serve_pnpr_handshake)) - .route("/-/pnpr/v0/resolve", post(serve_resolve)) - .route("/-/pnpr/v0/verify-lockfile", post(serve_verify_lockfile)); + .route( + "/-/pnpr/v0/resolve", + post(serve_resolve).route_layer(middleware::from_fn_with_state( + state.clone(), + require_resolver_caller, + )), + ) + .route( + "/-/pnpr/v0/verify-lockfile", + post(serve_verify_lockfile).route_layer(middleware::from_fn_with_state( + state.clone(), + require_resolver_caller, + )), + ); } else { router = router.route("/-/pnpr", any(resolver_disabled)); } @@ -374,11 +385,10 @@ fn router_with_auth_and_osv( .with_state(state) } -/// Bind to `config.listen` and serve forever. When the registry surface -/// is enabled, loads its htpasswd users and token database before binding -/// the socket so a startup-time auth error surfaces before we accept any -/// client connections; a resolver-only server skips journal recovery and -/// on-disk auth entirely and stays stateless. +/// Bind to `config.listen` and serve forever. Loads auth state before +/// binding so a startup-time auth error surfaces before we accept any +/// client connections. Registry startup additionally recovers the publish +/// journal. pub async fn serve(config: Config) -> crate::error::Result<()> { // Enforce the "at least one surface" invariant here too, not only at // YAML load / CLI: embedders build `Config` programmatically and call @@ -424,11 +434,9 @@ pub async fn serve_listener( config.ensure_a_feature_is_enabled()?; log_enabled_surfaces(&config); let osv_index = load_active_osv_index(&config)?; - // Load the configured auth backends here too (when the registry is - // enabled) — going through `router` would silently fall back to - // in-memory auth and ignore a persisted htpasswd / SQLite store or a - // configured `backend:`. A resolver-only server intentionally uses - // in-memory auth (see [`load_startup_auth`]). + // Load the configured auth backends here too — going through `router` + // would silently fall back to in-memory auth and ignore a persisted + // htpasswd / SQLite store or a configured `backend:`. let auth = load_startup_auth(&config).await?; let app = router_with_auth_and_osv(config, auth, osv_index); tracing::info!(%listen, "pnpr listening"); @@ -1237,6 +1245,43 @@ fn require_caller(identity: &Identity, resource: &str) -> Result Result, RegistryError> { + let authorization = single_authorization_header(headers)?; + identify(authorization, state.inner.auth.users.as_ref(), state.inner.auth.tokens.as_ref()).await +} + +async fn require_resolver_caller( + State(state): State, + request: Request, + next: Next, +) -> Response { + match caller_username(&state, request.headers()).await { + Ok(Some(_username)) => next.run(request).await, + Ok(None) => error_response(&RegistryError::Unauthenticated { + resource: "dependency resolution".to_string(), + }), + Err(error) => error_response(&error), + } +} + +fn single_authorization_header(headers: &HeaderMap) -> Result, RegistryError> { + let mut values = headers.get_all(header::AUTHORIZATION).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(RegistryError::BadRequest { + reason: "multiple Authorization headers are not allowed".to_string(), + }); + } + value.to_str().map(Some).map_err(|_| RegistryError::BadRequest { + reason: "Authorization header is not valid text".to_string(), + }) +} + fn json_response(status: StatusCode, body: &Value) -> Response { let bytes = serde_json::to_vec(body).expect("static-shape JSON serializes"); Response::builder() @@ -2068,11 +2113,10 @@ async fn authenticate(State(state): State, mut request: Request, next: // Copy what resolution needs out of the request before mutating its // extensions below — the header and method borrows can't outlive the // `extensions_mut` call. - let header = request - .headers() - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); + let header = match single_authorization_header(request.headers()) { + Ok(header) => header.map(str::to_owned), + Err(err) => return error_response(&err), + }; let method = request.method().clone(); let peer = request.extensions().get::>().map(|info| info.0.0); diff --git a/pnpr/crates/pnpr/tests/auth_persistence.rs b/pnpr/crates/pnpr/tests/auth_persistence.rs index e1e75caf77..d5d4ba942d 100644 --- a/pnpr/crates/pnpr/tests/auth_persistence.rs +++ b/pnpr/crates/pnpr/tests/auth_persistence.rs @@ -10,7 +10,7 @@ use axum::{ http::{Request, StatusCode}, }; use pnpr::{ - AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokensConfig, router_with_auth, + AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokensConfig, router, router_with_auth, }; use serde_json::{Value, json}; use std::{ @@ -283,3 +283,43 @@ async fn max_users_minus_one_disables_registration_end_to_end() { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } + +#[tokio::test] +async fn missing_max_users_disables_registration_end_to_end() { + let storage = TempDir::new().unwrap(); + let config = Config::static_serve(listen(), storage.path().to_path_buf()); + let app = router(config); + + let response = app + .oneshot(put_json("/-/user/org.couchdb.user:newbie", adduser_body("newbie", "anything"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_bytes(response.into_body()).await; + assert!( + !body.windows(b"\"token\"".len()).any(|window| window == b"\"token\""), + "registration denial must not issue a token", + ); +} + +#[tokio::test] +async fn finite_max_users_reaches_in_memory_backend_end_to_end() { + let storage = TempDir::new().unwrap(); + let mut config = Config::static_serve(listen(), storage.path().to_path_buf()); + config.auth.htpasswd.max_users = MaxUsers::Limited(1); + let auth = AuthState::load(&config.auth, &config.backend).await.unwrap(); + let app = router_with_auth(config, auth); + + let first = app + .clone() + .oneshot(put_json("/-/user/org.couchdb.user:alice", adduser_body("alice", "secret"))) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + + let second = app + .oneshot(put_json("/-/user/org.couchdb.user:bob", adduser_body("bob", "secret"))) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::FORBIDDEN); +} diff --git a/pnpr/crates/pnpr/tests/auth_publish.rs b/pnpr/crates/pnpr/tests/auth_publish.rs index 43ba266ad3..7d62c9a5df 100644 --- a/pnpr/crates/pnpr/tests/auth_publish.rs +++ b/pnpr/crates/pnpr/tests/auth_publish.rs @@ -11,7 +11,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, router}; +use pnpr::{Config, MaxUsers, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -24,6 +24,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } @@ -35,8 +36,9 @@ fn static_config_with_packages(dir: &TempDir, packages_block: &str) -> (Config, format!("storage: {}\nuplinks: {{}}\npackages:\n{packages_block}\n", storage.display()); let config_path = dir.path().join("config.yaml"); std::fs::write(&config_path, yaml).unwrap(); - let config = + let mut config = Config::from_yaml(&config_path, listen, Some("http://example.test".to_string())).unwrap(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; (config, storage) } diff --git a/pnpr/crates/pnpr/tests/auth_user_endpoints.rs b/pnpr/crates/pnpr/tests/auth_user_endpoints.rs index 51192ee14c..ffbff81677 100644 --- a/pnpr/crates/pnpr/tests/auth_user_endpoints.rs +++ b/pnpr/crates/pnpr/tests/auth_user_endpoints.rs @@ -6,7 +6,7 @@ use axum::{ body::{Body, to_bytes}, - http::{Request, StatusCode}, + http::{HeaderValue, Request, StatusCode, header}, }; use pnpr::{ AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokenStore, TokensConfig, @@ -29,6 +29,7 @@ fn listen() -> SocketAddr { fn static_config(storage: PathBuf) -> Config { let mut config = Config::static_serve(listen(), storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } @@ -175,6 +176,31 @@ async fn whoami_returns_401_for_unknown_bearer() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn whoami_rejects_duplicate_authorization_headers() { + let tmp = TempDir::new().unwrap(); + let app = router(static_config(tmp.path().to_path_buf())); + let (app, token) = add_user_and_get_token(app, "alice", "secret").await; + let mut request = get_with_bearer("/-/whoami", &token); + request + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-second-value")); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn whoami_rejects_non_text_authorization_header() { + let tmp = TempDir::new().unwrap(); + let app = router(static_config(tmp.path().to_path_buf())); + let mut request = Request::get("/-/whoami").body(Body::empty()).unwrap(); + request.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_bytes(&[0xff]).unwrap()); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn profile_returns_user_info() { let tmp = TempDir::new().unwrap(); diff --git a/pnpr/crates/pnpr/tests/batch_publish.rs b/pnpr/crates/pnpr/tests/batch_publish.rs index 8390a3feb3..d3ae719c2c 100644 --- a/pnpr/crates/pnpr/tests/batch_publish.rs +++ b/pnpr/crates/pnpr/tests/batch_publish.rs @@ -7,7 +7,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, router}; +use pnpr::{Config, MaxUsers, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -20,6 +20,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } diff --git a/pnpr/crates/pnpr/tests/journal_recovery.rs b/pnpr/crates/pnpr/tests/journal_recovery.rs index 8a5156506d..d81cc17c68 100644 --- a/pnpr/crates/pnpr/tests/journal_recovery.rs +++ b/pnpr/crates/pnpr/tests/journal_recovery.rs @@ -8,7 +8,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, recover_publish_journal, router}; +use pnpr::{Config, MaxUsers, recover_publish_journal, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -21,6 +21,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } diff --git a/pnpr/crates/pnpr/tests/policy.rs b/pnpr/crates/pnpr/tests/policy.rs index 8fa9a56a66..fa7d1debb5 100644 --- a/pnpr/crates/pnpr/tests/policy.rs +++ b/pnpr/crates/pnpr/tests/policy.rs @@ -24,7 +24,10 @@ fn config_from_yaml(packages_block: &str) -> (TempDir, Config) { let dir = TempDir::new().unwrap(); let storage = dir.path().join("storage"); std::fs::create_dir_all(&storage).unwrap(); - let yaml = format!("storage: {}\nuplinks: {{}}\n{packages_block}\n", storage.display()); + let yaml = format!( + "storage: {}\nuplinks: {{}}\nauth:\n htpasswd:\n max_users: 100\n{packages_block}\n", + storage.display(), + ); let path = dir.path().join("config.yaml"); std::fs::write(&path, yaml).unwrap(); let config = diff --git a/pnpr/crates/pnpr/tests/s3_backend.rs b/pnpr/crates/pnpr/tests/s3_backend.rs index a9be91daff..a9ab24dc3f 100644 --- a/pnpr/crates/pnpr/tests/s3_backend.rs +++ b/pnpr/crates/pnpr/tests/s3_backend.rs @@ -10,7 +10,7 @@ use axum::{ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use futures_util::StreamExt; use object_store::{ObjectStore, memory::InMemory, path::Path as ObjectPath}; -use pnpr::{Config, HostedStoreConfig, router}; +use pnpr::{Config, HostedStoreConfig, MaxUsers, router}; use serde_json::{Value, json}; use std::{ fmt::Write, @@ -27,6 +27,7 @@ fn s3_config(storage: PathBuf, store: Arc) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config.hosted_store = HostedStoreConfig::S3 { store, prefix: String::new() }; config } diff --git a/pnpr/crates/pnpr/tests/server.rs b/pnpr/crates/pnpr/tests/server.rs index e48e2c92ee..960008c2c5 100644 --- a/pnpr/crates/pnpr/tests/server.rs +++ b/pnpr/crates/pnpr/tests/server.rs @@ -1,16 +1,22 @@ use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode}, + body::{Body, Bytes, to_bytes}, + http::{HeaderValue, Request, StatusCode, header}, }; use flate2::read::GzDecoder; -use pnpr::{Config, router}; +use futures_util::stream; +use pnpr::{AuthState, Config, router, router_with_auth}; use serde_json::{Value, json}; use ssri::{Algorithm, IntegrityOpts}; use std::{ + convert::Infallible, fs, io::Read as _, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, time::Duration, }; use tempfile::TempDir; @@ -33,6 +39,243 @@ async fn body_bytes(body: Body) -> Vec { to_bytes(body, usize::MAX).await.expect("read body").to_vec() } +fn git_resolve_request(repo_url: &str, authorization: Option<&str>) -> Request { + let body = json!({ + "dependencies": { + "git-dependency": format!("git+{repo_url}#main"), + }, + "registry": "http://127.0.0.1:1/", + "trustLockfile": true, + "preferFrozenLockfile": false, + }); + let mut request = + Request::post("/-/pnpr/v0/resolve").header("content-type", "application/json"); + if let Some(authorization) = authorization { + request = request.header("authorization", authorization); + } + request.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} + +fn verify_lockfile_request(registry_url: &str, authorization: Option<&str>) -> Request { + let body = json!({ + "registry": registry_url, + "lockfile": { + "lockfileVersion": "9.0", + "settings": { + "autoInstallPeers": true, + "excludeLinksFromLockfile": false, + }, + "importers": { + ".": { + "dependencies": { + "probe-pkg": { + "specifier": "1.0.0", + "version": "1.0.0", + }, + }, + }, + }, + "packages": { + "probe-pkg@1.0.0": { + "resolution": { + "integrity": "sha512-xxzPGZ4P2uN6rROUa5N9Z7zTX6ERuE0hs6GUOc/cKBLF2NqKc16UwqHMt3tFg4CO6EBTE5UecUasg+3jZx3Ckg==", + }, + }, + }, + "snapshots": { + "probe-pkg@1.0.0": {}, + }, + }, + "minimumReleaseAge": 1, + "minimumReleaseAgeIgnoreMissingTime": false, + }); + let mut request = + Request::post("/-/pnpr/v0/verify-lockfile").header("content-type", "application/json"); + if let Some(authorization) = authorization { + request = request.header("authorization", authorization); + } + request.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} + +async fn drain_resolve_response(response: axum::response::Response) -> (StatusCode, Vec) { + let status = response.status(); + let body = tokio::time::timeout(Duration::from_secs(10), body_bytes(response.into_body())) + .await + .expect("resolver response should finish within 10 seconds"); + (status, body) +} + +async fn spawn_git_probe() -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let request_count = Arc::new(AtomicUsize::new(0)); + let probe_count = Arc::clone(&request_count); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + probe_count.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 500 Internal Server Error\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n", + ) + .await; + }); + } + }); + (format!("http://{addr}/repo.git"), request_count) +} + +#[tokio::test] +async fn anonymous_resolve_cannot_trigger_git_egress() { + let (repo_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let response = app.oneshot(git_resolve_request(&repo_url, None)).await.unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(String::from_utf8_lossy(&body).contains("Authentication required")); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn default_registration_cannot_mint_a_resolver_credential() { + let (repo_url, request_count) = spawn_git_probe().await; + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let registration = json!({ + "_id": "org.couchdb.user:outsider", + "name": "outsider", + "password": "secret", + "email": "outsider@example.test", + "type": "user", + "roles": [], + }); + let response = app + .clone() + .oneshot( + Request::put("/-/user/org.couchdb.user:outsider") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(®istration).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(!String::from_utf8_lossy(&body_bytes(response.into_body()).await).contains("token")); + + let response = app.oneshot(git_resolve_request(&repo_url, None)).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_resolve_is_rejected_before_the_body_is_collected() { + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let body = Body::from_stream(stream::pending::>()); + let request = Request::post("/-/pnpr/v0/resolve") + .header("content-type", "application/json") + .header("content-length", "1000000") + .body(body) + .unwrap(); + + let response = tokio::time::timeout(Duration::from_millis(250), app.oneshot(request)) + .await + .expect("authentication must finish without waiting for the request body") + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn resolve_rejects_duplicate_authorization_headers() { + let (repo_url, request_count) = spawn_git_probe().await; + let tmp = TempDir::new().unwrap(); + let auth = AuthState::in_memory(); + let token = auth.tokens.issue("alice").await.unwrap(); + let app = router_with_auth(config_for("http://127.0.0.1:1", tmp.path().to_path_buf()), auth); + let mut request = git_resolve_request(&repo_url, Some(&format!("Bearer {token}"))); + request + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-second-value")); + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let mut reversed = git_resolve_request(&repo_url, None); + reversed + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-first-value")); + reversed + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}")).unwrap()); + let response = app.clone().oneshot(reversed).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let mut non_text = git_resolve_request(&repo_url, None); + non_text.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_bytes(&[0xff]).unwrap()); + let response = app.oneshot(non_text).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_verify_lockfile_cannot_trigger_registry_egress() { + let (registry_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let response = app.oneshot(verify_lockfile_request(®istry_url, None)).await.unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(String::from_utf8_lossy(&body).contains("Authentication required")); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_verify_lockfile_is_rejected_before_the_body_is_collected() { + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let body = Body::from_stream(stream::pending::>()); + let request = Request::post("/-/pnpr/v0/verify-lockfile") + .header("content-type", "application/json") + .header("content-length", "1000000") + .body(body) + .unwrap(); + + let response = tokio::time::timeout(Duration::from_millis(250), app.oneshot(request)) + .await + .expect("authentication must finish without waiting for the request body") + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn authenticated_resolve_preserves_git_dependencies() { + let (repo_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let auth = AuthState::in_memory(); + let token = auth.tokens.issue("alice").await.unwrap(); + let app = router_with_auth(config_for("http://127.0.0.1:1", tmp.path().to_path_buf()), auth); + let response = app + .oneshot(git_resolve_request(&repo_url, Some(&format!("Bearer {token}")))) + .await + .unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(String::from_utf8_lossy(&body).contains(r#""type":"error""#)); + assert!(request_count.load(Ordering::SeqCst) >= 1); +} + async fn body_json(body: Body) -> Value { serde_json::from_slice(&body_bytes(body).await).expect("body parses as JSON") } @@ -1606,8 +1849,8 @@ async fn resolver_only_serves_resolver_endpoints_and_refuses_registry_routes() { let app = router(config); // The resolver surface stays reachable. `/-/ping` and the capability - // handshake answer 200; `/-/pnpr/v0/verify-lockfile` is mounted, so an empty - // body is a 400 (bad request) rather than a 404 (route absent) — that + // handshake answer 200; `/-/pnpr/v0/verify-lockfile` is mounted and gated, + // so an anonymous request is a 401 rather than a 404 (route absent) — that // distinction is the point of the assertion. let ping = app.clone().oneshot(Request::get("/-/ping").body(Body::empty()).unwrap()).await.unwrap(); @@ -1622,11 +1865,7 @@ async fn resolver_only_serves_resolver_endpoints_and_refuses_registry_routes() { .oneshot(Request::post("/-/pnpr/v0/verify-lockfile").body(Body::empty()).unwrap()) .await .unwrap(); - assert_ne!( - verify.status(), - StatusCode::NOT_FOUND, - "the verify-lockfile route must stay mounted when the registry is disabled", - ); + assert_eq!(verify.status(), StatusCode::UNAUTHORIZED); // Every npm-registry route is gone, not merely hidden: a packument // read, a publish, and a batch publish all 404 without any upstream