Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
Matt W1//! The §9 security checklist, one test per item (M6).
Matt W2//!
Matt W3//! > **Private repo leakage.** The most common forge vulnerability class. […]
Matt W4//! > Deliberately test: private repo object access via a public fork's URL, blob
Matt W5//! > access by OID without a reachable ref, change and issue numbers enumerated
Matt W6//! > across visibility boundaries, and search results crossing repos. Every one
Matt W7//! > of these has been a real CVE in a real forge.
Matt W8//!
Matt W9//! These drive the **real router** — every middleware, every extractor, every
Matt W10//! handler — through `tower::ServiceExt::oneshot`. Testing the SQL directly
Matt W11//! would miss exactly the bugs that matter here, which live in the seam between
Matt W12//! a handler and the authorization it forgot to call.
Matt W13//!
Matt W14//! They need a throwaway Postgres. Set `DF_TEST_DATABASE_URL`; without it they
Matt W15//! skip with a warning, and `DF_REQUIRE_DB=1` turns that skip into a failure so
Matt W16//! CI cannot quietly stop running them.
Matt W17//!
Matt W18//! ```sh
Matt W19//! docker run -d --name df-pgtest -e POSTGRES_PASSWORD=x -e POSTGRES_DB=t \
Matt W20//! -p 55432:5432 postgres:17-alpine
Matt W21//! DF_TEST_DATABASE_URL=postgres://postgres:x@127.0.0.1:55432/t \
Matt W22//! cargo test -p df-web security
Matt W23//! ```
Matt W24//!
Matt W25//! Each test runs in its own Postgres **schema**, created and dropped around it,
Matt W26//! so they neither collide nor need ordering.
Matt W27
Matt W28#![cfg(test)]
Matt W29
Matt W30use std::path::Path;
Matt W31use std::sync::Arc;
Matt W32
Matt W33use axum::body::Body;
Matt W34use axum::http::{Request, StatusCode};
Matt W35use axum::response::Response;
Matt W36use df_store::{
Matt W37 BlameLine, Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, MergeOutcome, RepoId,
Matt W38 RepoStore, Result as SResult, RevId, Revision, Signature, StoreError, TreeEntry,
Matt W39};
Matt W40use sqlx::{Executor, PgPool};
Matt W41use tower::ServiceExt;
Matt W42use uuid::Uuid;
Matt W43
Matt W44use crate::config::Config;
Matt W45use crate::state::{AppState, Inner};
Matt W46
Matt W47// ─── harness ─────────────────────────────────────────────────────────────────
Matt W48
Matt W49/// A store that has nothing in it.
Matt W50///
Matt W51/// The visibility tests are about *authorization*, and authorization must be
Matt W52/// decided before storage is touched. A store that refuses everything makes that
Matt W53/// falsifiable: if a handler ever reaches storage on an unauthorized request,
Matt W54/// the response changes from 404 to 500 and the test fails.
Matt W55struct EmptyStore;
Matt W56
Matt W57#[async_trait::async_trait]
Matt W58impl RepoStore for EmptyStore {
Matt W59 async fn create(&self, _: RepoId, _: &str) -> SResult<()> {
Matt W60 Ok(())
Matt W61 }
Matt W62 async fn configure_receive_validation(&self, _: RepoId, _: &str) -> SResult<()> {
Matt W63 Ok(())
Matt W64 }
Matt W65 async fn delete(&self, _: RepoId) -> SResult<()> {
Matt W66 Ok(())
Matt W67 }
Matt W68 async fn exists(&self, _: RepoId) -> bool {
Matt W69 true
Matt W70 }
Matt W71 async fn is_empty(&self, _: RepoId) -> SResult<bool> {
Matt W72 Ok(true)
Matt W73 }
Matt W74 async fn list_tree(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<TreeEntry>> {
Matt W75 Err(StoreError::NoSuchRevision)
Matt W76 }
Matt W77 async fn read_blob(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Blob> {
Matt W78 Err(StoreError::NoSuchRevision)
Matt W79 }
Matt W80 async fn diff(&self, _: RepoId, _: &RevId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
Matt W81 Err(StoreError::NoSuchRevision)
Matt W82 }
Matt W83 async fn diff_from_parent(&self, _: RepoId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
Matt W84 Err(StoreError::NoSuchRevision)
Matt W85 }
Matt W86 async fn log(&self, _: RepoId, _: &RevId, _: usize) -> SResult<Vec<Revision>> {
Matt W87 Err(StoreError::NoSuchRevision)
Matt W88 }
Matt W89 async fn revision(&self, _: RepoId, _: &RevId) -> SResult<Revision> {
Matt W90 Err(StoreError::NoSuchRevision)
Matt W91 }
Matt W92 async fn bookmarks(&self, _: RepoId) -> SResult<Vec<Bookmark>> {
Matt W93 Ok(vec![])
Matt W94 }
Matt W95 async fn merge_base(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<Option<RevId>> {
Matt W96 Ok(None)
Matt W97 }
Matt W98 async fn is_ancestor(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<bool> {
Matt W99 Ok(false)
Matt W100 }
Matt W101 async fn resolve(&self, _: RepoId, _: &str) -> SResult<RevId> {
Matt W102 Err(StoreError::NoSuchRevision)
Matt W103 }
Matt W104 async fn merge(
Matt W105 &self,
Matt W106 _: RepoId,
Matt W107 _: &str,
Matt W108 _: &RevId,
Matt W109 _: &str,
Matt W110 _: &Signature,
Matt W111 ) -> SResult<MergeOutcome> {
Matt W112 Err(StoreError::NoSuchRepo)
Matt W113 }
Matt W114 async fn commit_file(
Matt W115 &self,
Matt W116 _: RepoId,
Matt W117 _: &str,
Matt W118 _: &RevId,
Matt W119 _: &str,
Matt W120 _: Vec<u8>,
Matt W121 _: &str,
Matt W122 _: &Signature,
Matt W123 ) -> SResult<EditOutcome> {
Matt W124 Err(StoreError::NoSuchRepo)
Matt W125 }
Matt W126 async fn conflicts(&self, _: RepoId, _: &RevId) -> SResult<Vec<ConflictedFile>> {
Matt W127 Ok(vec![])
Matt W128 }
Matt W129 async fn size_bytes(&self, _: RepoId) -> SResult<u64> {
Matt W130 Ok(0)
Matt W131 }
Matt W132 async fn blame(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<BlameLine>> {
Matt W133 Err(StoreError::NoSuchRevision)
Matt W134 }
Matt W135 async fn last_commit_for_path(
Matt W136 &self,
Matt W137 _: RepoId,
Matt W138 _: &RevId,
Matt W139 _: &Path,
Matt W140 ) -> SResult<Option<Revision>> {
Matt W141 Ok(None)
Matt W142 }
Matt W143 async fn last_commits_in_dir(
Matt W144 &self,
Matt W145 _: RepoId,
Matt W146 _: &RevId,
Matt W147 _: &Path,
Matt W148 _: &[String],
Matt W149 ) -> SResult<std::collections::HashMap<String, Revision>> {
Matt W150 Ok(std::collections::HashMap::new())
Matt W151 }
Matt W152 async fn diff_stats(&self, _: RepoId, revs: &[RevId]) -> SResult<Vec<Option<(usize, usize)>>> {
Matt W153 Ok(vec![None; revs.len()])
Matt W154 }
Matt W155}
Matt W156
Matt W157/// Enough provider metadata for `Oidc` to construct without a network call.
Matt W158const METADATA: &str = r#"{
Matt W159 "issuer": "https://oidc.test/",
Matt W160 "authorization_endpoint": "https://oidc.test/auth",
Matt W161 "token_endpoint": "https://oidc.test/token",
Matt W162 "jwks_uri": "https://oidc.test/jwks",
Matt W163 "response_types_supported": ["code"],
Matt W164 "subject_types_supported": ["public"],
Matt W165 "id_token_signing_alg_values_supported": ["RS256"]
Matt W166}"#;
Matt W167
Matt W168struct Harness {
Matt W169 app: axum::Router,
Matt W170 db: PgPool,
Matt W171 schema: String,
Matt W172}
Matt W173
Matt W174impl Harness {
Matt W175 /// A GET as an anonymous visitor.
Matt W176 async fn get(&self, path: &str) -> Response {
Matt W177 self.request(path, None).await
Matt W178 }
Matt W179
Matt W180 /// A GET as a signed-in user.
Matt W181 async fn get_as(&self, path: &str, session: &str) -> Response {
Matt W182 self.request(path, Some(session)).await
Matt W183 }
Matt W184
Matt W185 async fn request(&self, path: &str, session: Option<&str>) -> Response {
Matt W186 let mut req = Request::builder().uri(path).method("GET");
Matt W187 if let Some(s) = session {
Matt W188 req = req.header("cookie", format!("{}={s}", df_auth::session::COOKIE_NAME));
Matt W189 }
Matt W190 self.app
Matt W191 .clone()
Matt W192 .oneshot(req.body(Body::empty()).expect("request"))
Matt W193 .await
Matt W194 .expect("response")
Matt W195 }
Matt W196
Matt W197 async fn body(&self, res: Response) -> String {
Matt W198 let bytes = axum::body::to_bytes(res.into_body(), 4 * 1024 * 1024)
Matt W199 .await
Matt W200 .expect("body");
Matt W201 String::from_utf8_lossy(&bytes).into_owned()
Matt W202 }
Matt W203
Matt W204 async fn drop_schema(&self) {
Matt W205 let _ = self
Matt W206 .db
Matt W207 .execute(format!("DROP SCHEMA IF EXISTS {} CASCADE", self.schema).as_str())
Matt W208 .await;
Matt W209 }
Matt W210}
Matt W211
Matt W212/// Send `tracing` output to the test's captured stderr, once per process.
Matt W213///
Matt W214/// Without this an internal error is a bare 500 in the assertion and the reason
Matt W215/// is thrown away — which is exactly the information a failing security test
Matt W216/// needs to hand over.
Matt W217fn init_tracing() {
Matt W218 use std::sync::Once;
Matt W219 static ONCE: Once = Once::new();
Matt W220 ONCE.call_once(|| {
Matt W221 let _ = tracing_subscriber::fmt()
Matt W222 .with_env_filter(
Matt W223 tracing_subscriber::EnvFilter::try_from_default_env()
Matt W224 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("df_web=error")),
Matt W225 )
Matt W226 .with_test_writer()
Matt W227 .try_init();
Matt W228 });
Matt W229}
Matt W230
Matt W231/// Build a harness, or `None` when no test database is configured.
Matt W232async fn harness(name: &str) -> Option<Harness> {
Matt W233 init_tracing();
Matt W234
Matt W235 let Ok(url) = std::env::var("DF_TEST_DATABASE_URL") else {
Matt W236 if std::env::var_os("DF_REQUIRE_DB").is_some() {
Matt W237 panic!("DF_REQUIRE_DB is set but DF_TEST_DATABASE_URL is not");
Matt W238 }
Matt W239 eprintln!("warning: DF_TEST_DATABASE_URL unset, security test `{name}` skipped");
Matt W240 return None;
Matt W241 };
Matt W242
Matt W243 // One schema per test, named after the test. Deriving it from the name
Matt W244 // rather than from a random id makes collisions impossible — two tests
Matt W245 // cannot share a name — so they stay independent while running in parallel.
Matt W246 let schema: String = format!("df_test_{name}")
Matt W247 .chars()
Matt W248 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
Matt W249 .take(60)
Matt W250 .collect();
Matt W251
Matt W252 let db = sqlx::postgres::PgPoolOptions::new()
Matt W253 .max_connections(4)
Matt W254 .after_connect({
Matt W255 let schema = schema.clone();
Matt W256 move |conn, _| {
Matt W257 let schema = schema.clone();
Matt W258 Box::pin(async move {
Matt W259 conn.execute(format!("SET search_path TO {schema}, public").as_str())
Matt W260 .await?;
Matt W261 Ok(())
Matt W262 })
Matt W263 }
Matt W264 })
Matt W265 .connect(&url)
Matt W266 .await
Matt W267 .expect("connecting to DF_TEST_DATABASE_URL");
Matt W268
Matt W269 // Dropped first: a previous run that panicked before its cleanup would
Matt W270 // otherwise leave rows behind and make this run fail for the wrong reason.
Matt W271 db.execute(format!("DROP SCHEMA IF EXISTS {schema} CASCADE").as_str())
Matt W272 .await
Matt W273 .expect("dropping any leftover test schema");
Matt W274 db.execute(format!("CREATE SCHEMA {schema}").as_str())
Matt W275 .await
Matt W276 .expect("creating the test schema");
Matt W277
Matt W278 // Extensions are database-wide, and `CREATE EXTENSION` installs into the
Matt W279 // first schema on the search path. Pinning them to `public` up front stops
Matt W280 // the first test that runs from installing them into its own schema and
Matt W281 // taking them away when it drops it — the `IF NOT EXISTS` in the migration
Matt W282 // then correctly does nothing.
Matt W283 // `CREATE EXTENSION IF NOT EXISTS` is not safe to run concurrently: the
Matt W284 // existence check and the insert are not atomic, so test binaries running
Matt W285 // in parallel against a *fresh* database collide on `pg_extension`'s unique
Matt W286 // index and fail here. An advisory lock serialises just this step. Only the
Matt W287 // first run on a new database ever contends — which is exactly the run
Matt W288 // where the failure is most confusing, because it looks like the security
Matt W289 // tests themselves are broken.
Matt W290 const EXTENSION_LOCK: i64 = 0x6466_5F65_7874; // "df_ext"
Matt W291
Matt W292 let mut conn = db.acquire().await.expect("a connection for extension setup");
Matt W293
Matt W294 sqlx::query("SELECT pg_advisory_lock($1)")
Matt W295 .bind(EXTENSION_LOCK)
Matt W296 .execute(&mut *conn)
Matt W297 .await
Matt W298 .expect("taking the extension lock");
Matt W299
Matt W300 let installed = async {
Matt W301 for ext in ["citext", "pg_trgm"] {
Matt W302 conn.execute(format!("CREATE EXTENSION IF NOT EXISTS {ext} SCHEMA public").as_str())
Matt W303 .await?;
Matt W304 }
Matt W305 Ok::<_, sqlx::Error>(())
Matt W306 }
Matt W307 .await;
Matt W308
Matt W309 // Released whether or not the install worked, so a failure here does not
Matt W310 // wedge every other test binary waiting on the lock.
Matt W311 sqlx::query("SELECT pg_advisory_unlock($1)")
Matt W312 .bind(EXTENSION_LOCK)
Matt W313 .execute(&mut *conn)
Matt W314 .await
Matt W315 .expect("releasing the extension lock");
Matt W316
Matt W317 installed.expect("installing an extension into public");
Matt W318 drop(conn);
Matt W319
Matt W320 df_db::migrate(&db).await.expect("migrations");
Matt W321
Matt W322 // The schema on the search path must be the one that owns the tables. If a
Matt W323 // previous run ever migrated into `public`, every query here would silently
Matt W324 // read *that* schema instead — the tests would still pass, against the wrong
Matt W325 // data, until a new migration made them disagree. Caught here rather than
Matt W326 // three layers down in a handler.
Matt W327 let owned: Option<String> = sqlx::query_scalar(&format!(
Matt W328 "SELECT to_regclass('{schema}.repos')::text"
Matt W329 ))
Matt W330 .fetch_one(&db)
Matt W331 .await
Matt W332 .expect("checking schema ownership");
Matt W333 assert!(
Matt W334 owned.is_some(),
Matt W335 "migrations did not land in {schema}; is there a stale dogfood schema in `public`? \
Matt W336 Reset it with: DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
Matt W337 );
Matt W338
Matt W339 let config = Config::for_tests();
Matt W340 let oidc = df_auth::Oidc::from_metadata_json(
Matt W341 METADATA,
Matt W342 "test-client",
Matt W343 "test-secret",
Matt W344 "https://dogfood.test/auth/callback",
Matt W345 "openid profile email",
Matt W346 )
Matt W347 .expect("test oidc client");
Matt W348
Matt W349 let state = AppState(Arc::new(Inner {
Matt W350 db: db.clone(),
Matt W351 oidc,
Matt W352 config,
Matt W353 store: Arc::new(EmptyStore),
Matt W354 limiter: crate::ratelimit::Limiter::new(),
Matt W355 }));
Matt W356
Matt W357 Some(Harness {
Matt W358 app: crate::build_router(state),
Matt W359 db,
Matt W360 schema,
Matt W361 })
Matt W362}
Matt W363
Matt W364/// Seed a user and an active session, returning `(user_id, session_token)`.
Matt W365///
Matt W366/// The second element is the cookie value, which is the token and not the row
Matt W367/// id — the id is never accepted as a credential.
Matt W368async fn user(db: &PgPool, handle: &str, admin: bool) -> (Uuid, String) {
Matt W369 let id = Uuid::now_v7();
Matt W370 sqlx::query(
Matt W371 "INSERT INTO users (id, subject, handle, display_name, is_admin)
Matt W372 VALUES ($1, $2, $3, $3, $4)",
Matt W373 )
Matt W374 .bind(id)
Matt W375 .bind(format!("sub-{handle}"))
Matt W376 .bind(handle)
Matt W377 .bind(admin)
Matt W378 .execute(db)
Matt W379 .await
Matt W380 .expect("seeding a user");
Matt W381
Matt W382 // Minted the same way a real login does, so the tests exercise the token
Matt W383 // path rather than a shape only the tests produce.
Matt W384 let token = df_auth::session::generate_token();
Matt W385 sqlx::query(
Matt W386 "INSERT INTO sessions (id, user_id, token_hash, expires_at)
Matt W387 VALUES ($1, $2, encode(sha256($3::bytea), 'hex'), now() + '14 days')",
Matt W388 )
Matt W389 .bind(Uuid::now_v7())
Matt W390 .bind(id)
Matt W391 .bind(token.as_bytes())
Matt W392 .execute(db)
Matt W393 .await
Matt W394 .expect("seeding a session");
Matt W395
Matt W396 (id, token)
Matt W397}
Matt W398
Matt W399/// Seed a repository owned by `owner`, returning its id.
Matt W400async fn repo(db: &PgPool, owner: Uuid, name: &str, private: bool) -> Uuid {
Matt W401 let id = Uuid::now_v7();
Matt W402 sqlx::query(
Matt W403 "INSERT INTO repos (id, owner_kind, owner_user_id, name, visibility, default_bookmark)
Matt W404 VALUES ($1, 'user', $2, $3, $4::visibility, 'main')",
Matt W405 )
Matt W406 .bind(id)
Matt W407 .bind(owner)
Matt W408 .bind(name)
Matt W409 .bind(if private { "private" } else { "public" })
Matt W410 .execute(db)
Matt W411 .await
Matt W412 .expect("seeding a repo");
Matt W413
Matt W414 sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
Matt W415 .bind(id)
Matt W416 .execute(db)
Matt W417 .await
Matt W418 .expect("seeding counters");
Matt W419
Matt W420 id
Matt W421}
Matt W422
Matt W423/// Seed a change with one revision.
Matt W424async fn change(db: &PgPool, repo_id: Uuid, number: i64, change_id: &str, title: &str) -> Uuid {
Matt W425 let id = Uuid::now_v7();
Matt W426 sqlx::query(
Matt W427 "INSERT INTO changes (id, repo_id, change_id, number, title, description,
Matt W428 state, target_bookmark)
Matt W429 VALUES ($1, $2, $3, $4, $5, 'secret description', 'open', 'main')",
Matt W430 )
Matt W431 .bind(id)
Matt W432 .bind(repo_id)
Matt W433 .bind(change_id)
Matt W434 .bind(number)
Matt W435 .bind(title)
Matt W436 .execute(db)
Matt W437 .await
Matt W438 .expect("seeding a change");
Matt W439
Matt W440 sqlx::query(
Matt W441 "INSERT INTO revisions (id, change_id_fk, rev, seq, author_name, author_email,
Matt W442 authored_at, message)
Matt W443 VALUES ($1, $2, $3, 1, 'A', 'a@b.c', now(), $4)",
Matt W444 )
Matt W445 .bind(Uuid::now_v7())
Matt W446 .bind(id)
Matt W447 .bind(format!("{:040x}", number))
Matt W448 .bind(title)
Matt W449 .execute(db)
Matt W450 .await
Matt W451 .expect("seeding a revision");
Matt W452
Matt W453 id
Matt W454}
Matt W455
Matt W456async fn issue(db: &PgPool, repo_id: Uuid, number: i64, title: &str) -> Uuid {
Matt W457 let id = Uuid::now_v7();
Matt W458 sqlx::query(
Matt W459 "INSERT INTO issues (id, repo_id, number, title, body)
Matt W460 VALUES ($1, $2, $3, $4, 'secret issue body')",
Matt W461 )
Matt W462 .bind(id)
Matt W463 .bind(repo_id)
Matt W464 .bind(number)
Matt W465 .bind(title)
Matt W466 .execute(db)
Matt W467 .await
Matt W468 .expect("seeding an issue");
Matt W469 id
Matt W470}
Matt W471
Matt W472// ─── the checklist ───────────────────────────────────────────────────────────
Matt W473
Matt W474/// > Return an identical 404 for a private repo and a nonexistent repo.
Matt W475#[tokio::test]
Matt W476async fn a_private_repo_and_a_nonexistent_repo_are_indistinguishable() {
Matt W477 let Some(h) = harness("identical_404").await else { return };
Matt W478
Matt W479 let (alice, _) = user(&h.db, "alice", false).await;
Matt W480 // A name that cannot appear in the chrome for any other reason — `hidden`
Matt W481 // would also match `aria-hidden` and `<input type="hidden">`, and the
Matt W482 // assertion would then fail for a reason that has nothing to do with the
Matt W483 // property being tested.
Matt W484 repo(&h.db, alice, "unmentionable-repo", true).await;
Matt W485
Matt W486 let private = h.get("/alice/unmentionable-repo").await;
Matt W487 let missing = h.get("/alice/does-not-exist").await;
Matt W488
Matt W489 assert_eq!(private.status(), StatusCode::NOT_FOUND);
Matt W490 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
Matt W491
Matt W492 let a = h.body(private).await;
Matt W493 let b = h.body(missing).await;
Matt W494 assert_eq!(a, b, "the two responses must be byte-identical");
Matt W495 assert!(
Matt W496 !a.contains("unmentionable-repo"),
Matt W497 "the repository name leaked into the 404"
Matt W498 );
Matt W499
Matt W500 h.drop_schema().await;
Matt W501}
Matt W502
Matt W503/// > Being authenticated is not authorization.
Matt W504#[tokio::test]
Matt W505async fn a_signed_in_stranger_cannot_reach_a_private_repo() {
Matt W506 let Some(h) = harness("stranger").await else { return };
Matt W507
Matt W508 let (alice, _) = user(&h.db, "alice", false).await;
Matt W509 let (_, mallory_session) = user(&h.db, "mallory", false).await;
Matt W510 repo(&h.db, alice, "hidden", true).await;
Matt W511
Matt W512 for path in [
Matt W513 "/alice/hidden",
Matt W514 "/alice/hidden/log",
Matt W515 "/alice/hidden/bookmarks",
Matt W516 "/alice/hidden/changes",
Matt W517 "/alice/hidden/issues",
Matt W518 "/alice/hidden/settings",
Matt W519 "/alice/hidden/tree/main/",
Matt W520 "/alice/hidden/blob/main/README.md",
Matt W521 "/alice/hidden/raw/main/README.md",
Matt W522 ] {
Matt W523 let res = h.get_as(path, &mallory_session).await;
Matt W524 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
Matt W525 }
Matt W526
Matt W527 h.drop_schema().await;
Matt W528}
Matt W529
Matt W530/// > Change and issue numbers enumerated across visibility boundaries.
Matt W531#[tokio::test]
Matt W532async fn change_and_issue_numbers_cannot_be_enumerated_across_a_boundary() {
Matt W533 let Some(h) = harness("enumeration").await else { return };
Matt W534
Matt W535 let (alice, _) = user(&h.db, "alice", false).await;
Matt W536 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W537
Matt W538 let hidden = repo(&h.db, alice, "hidden", true).await;
Matt W539 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret change title").await;
Matt W540 issue(&h.db, hidden, 1, "secret issue title").await;
Matt W541
Matt W542 for path in [
Matt W543 "/alice/hidden/changes/1",
Matt W544 "/alice/hidden/changes/klxqnvpq",
Matt W545 "/alice/hidden/changes/1/files",
Matt W546 "/alice/hidden/changes/1/revisions",
Matt W547 "/alice/hidden/changes/1/conflicts",
Matt W548 "/alice/hidden/issues/1",
Matt W549 "/alice/hidden/stacks/klxqnvpqlnlvtkmuqmtmxktlnvnomvwv",
Matt W550 ] {
Matt W551 for session in [None, Some(mallory.as_str())] {
Matt W552 let res = h.request(path, session).await;
Matt W553 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
Matt W554 let body = h.body(res).await;
Matt W555 assert!(!body.contains("secret"), "{path} leaked content: {body}");
Matt W556 }
Matt W557 }
Matt W558
Matt W559 h.drop_schema().await;
Matt W560}
Matt W561
Matt W562/// > Blob access by OID without a reachable ref.
Matt W563///
Matt W564/// The route requires a repository the viewer can read *before* it resolves any
Matt W565/// revision, so a raw object id is not a way around visibility. The store used
Matt W566/// here refuses every read, which is what proves the 404 came from
Matt W567/// authorization rather than from a missing object.
Matt W568#[tokio::test]
Matt W569async fn a_raw_object_id_is_not_a_way_around_visibility() {
Matt W570 let Some(h) = harness("blob_by_oid").await else { return };
Matt W571
Matt W572 let (alice, _) = user(&h.db, "alice", false).await;
Matt W573 repo(&h.db, alice, "hidden", true).await;
Matt W574
Matt W575 let oid = "0123456789abcdef0123456789abcdef01234567";
Matt W576 for path in [
Matt W577 format!("/alice/hidden/blob/{oid}/src/secret.rs"),
Matt W578 format!("/alice/hidden/raw/{oid}/src/secret.rs"),
Matt W579 format!("/alice/hidden/tree/{oid}/"),
Matt W580 format!("/alice/hidden/log?rev={oid}"),
Matt W581 ] {
Matt W582 let res = h.get(&path).await;
Matt W583 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
Matt W584 }
Matt W585
Matt W586 h.drop_schema().await;
Matt W587}
Matt W588
Matt W589/// > Private repo object access via a public fork's URL.
Matt W590///
Matt W591/// Decided §13.5: forks get separate storage, no Git alternates. So a fork's URL
Matt W592/// resolves to the fork's own repository row and its own storage — the parent's
Matt W593/// visibility is irrelevant because the parent's objects are not there at all.
Matt W594/// This pins that the fork relationship grants nothing.
Matt W595#[tokio::test]
Matt W596async fn a_public_fork_does_not_expose_its_private_parent() {
Matt W597 let Some(h) = harness("fork").await else { return };
Matt W598
Matt W599 let (alice, _) = user(&h.db, "alice", false).await;
Matt W600 let (bob, _) = user(&h.db, "bob", false).await;
Matt W601 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W602
Matt W603 let parent = repo(&h.db, alice, "private-parent", true).await;
Matt W604 let fork = repo(&h.db, bob, "public-fork", false).await;
Matt W605 sqlx::query("UPDATE repos SET fork_of_repo_id = $2 WHERE id = $1")
Matt W606 .bind(fork)
Matt W607 .bind(parent)
Matt W608 .execute(&h.db)
Matt W609 .await
Matt W610 .unwrap();
Matt W611
Matt W612 // Content that exists only in the parent.
Matt W613 change(&h.db, parent, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret parent change").await;
Matt W614 issue(&h.db, parent, 1, "secret parent issue").await;
Matt W615
Matt W616 // The fork is public and readable…
Matt W617 assert_eq!(h.get("/bob/public-fork").await.status(), StatusCode::OK);
Matt W618
Matt W619 // …but the parent's content is not reachable through it, by number or by
Matt W620 // change id, and the parent itself stays a 404.
Matt W621 for path in [
Matt W622 "/bob/public-fork/changes/1",
Matt W623 "/bob/public-fork/changes/klxqnvpq",
Matt W624 "/bob/public-fork/issues/1",
Matt W625 "/alice/private-parent",
Matt W626 ] {
Matt W627 for session in [None, Some(mallory.as_str())] {
Matt W628 let res = h.request(path, session).await;
Matt W629 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
Matt W630 let body = h.body(res).await;
Matt W631 assert!(!body.contains("secret parent"), "{path} leaked: {body}");
Matt W632 }
Matt W633 }
Matt W634
Matt W635 h.drop_schema().await;
Matt W636}
Matt W637
Matt W638/// > Search results crossing repos.
Matt W639#[tokio::test]
Matt W640async fn search_never_returns_anything_the_viewer_cannot_open() {
Matt W641 let Some(h) = harness("search").await else { return };
Matt W642
Matt W643 let (alice, alice_session) = user(&h.db, "alice", false).await;
Matt W644 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W645
Matt W646 // The last term of a query is a *prefix* match, so both names must start
Matt W647 // with the term for the search to be a fair test of visibility rather than
Matt W648 // of tokenisation.
Matt W649 let hidden = repo(&h.db, alice, "widgetsecret", true).await;
Matt W650 let public = repo(&h.db, alice, "widgetopen", false).await;
Matt W651
Matt W652 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
Matt W653 issue(&h.db, hidden, 1, "widgetconfidential issue").await;
Matt W654 change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
Matt W655
Matt W656 for session in [None, Some(mallory.as_str())] {
Matt W657 let res = h.request("/search?q=widget", session).await;
Matt W658 assert_eq!(res.status(), StatusCode::OK);
Matt W659 let body = h.body(res).await;
Matt W660
Matt W661 assert!(!body.contains("widgetconfidential"), "private content in search: {body}");
Matt W662 assert!(!body.contains("widgetsecret"), "private repo name in search: {body}");
Matt W663 assert!(body.contains("widgetopen"), "the public repo should be findable");
Matt W664 }
Matt W665
Matt W666 // The owner does see their own private content, or the feature is useless.
Matt W667 let res = h.get_as("/search?q=widget", &alice_session).await;
Matt W668 let body = h.body(res).await;
Matt W669 assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
Matt W670
Matt W671 h.drop_schema().await;
Matt W672}
Matt W673
Matt W674/// The redesign added several aggregate queries that only run at request time —
Matt W675/// the sub-bar counts, the filter-tab counts, and the weekly statistics. A
Matt W676/// mistake in any of them is a 500 on the two most-visited pages in a
Matt W677/// repository, and nothing else in the suite would catch it.
Matt W678///
Matt W679/// Not a security property, but it lives here because this is the only
Matt W680/// harness that can serve a real request against a real schema.
Matt W681#[tokio::test]
Matt W682async fn the_repository_pages_render_with_their_real_counts() {
Matt W683 let Some(h) = harness("counts").await else { return };
Matt W684
Matt W685 let (alice, session) = user(&h.db, "alice", false).await;
Matt W686 let r = repo(&h.db, alice, "counted", false).await;
Matt W687
Matt W688 change(&h.db, r, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "first change").await;
Matt W689 change(&h.db, r, 2, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "second change").await;
Matt W690 issue(&h.db, r, 1, "an issue").await;
Matt W691
Matt W692 for path in ["/alice/counted/changes", "/alice/counted/issues", "/alice/counted/bookmarks"] {
Matt W693 let res = h.get_as(path, &session).await;
Matt W694 assert_eq!(res.status(), StatusCode::OK, "{path} did not render");
Matt W695 let body = h.body(res).await;
Matt W696
Matt W697 // The sub-bar states the repository's vital signs on every page, so
Matt W698 // its numbers are the ones that must be right everywhere.
Matt W699 assert!(
Matt W700 body.contains("2 open"),
Matt W701 "{path} should report two open changes in the sub-bar: {body}"
Matt W702 );
Matt W703 }
Matt W704
Matt W705 // The change list additionally runs the tab counts and the weekly
Matt W706 // aggregate, including a percentile over an empty set — which returns
Matt W707 // NULL, and must decode as `None` rather than failing the query.
Matt W708 let res = h.get_as("/alice/counted/changes", &session).await;
Matt W709 let body = h.body(res).await;
Matt W710 assert!(body.contains("second change"), "the list should show its changes: {body}");
Matt W711 assert!(body.contains("Median time to first review"), "week stats missing: {body}");
Matt W712
Matt W713 h.drop_schema().await;
Matt W714}
Matt W715
Matt W716/// The ⌘K palette is a second entry point into search, and a second entry
Matt W717/// point is exactly where a visibility rule gets forgotten.
Matt W718///
Matt W719/// It must not be: the palette renders through the same handler in fragment
Matt W720/// mode, so this asserts the property directly rather than trusting that it is
Matt W721/// the same code path.
Matt W722#[tokio::test]
Matt W723async fn the_palette_fragment_obeys_the_same_visibility_rule_as_search() {
Matt W724 let Some(h) = harness("palette").await else { return };
Matt W725
Matt W726 let (alice, alice_session) = user(&h.db, "alice", false).await;
Matt W727 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W728
Matt W729 let hidden = repo(&h.db, alice, "widgetsecret", true).await;
Matt W730 let public = repo(&h.db, alice, "widgetopen", false).await;
Matt W731 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
Matt W732 change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
Matt W733
Matt W734 for session in [None, Some(mallory.as_str())] {
Matt W735 let res = h.request("/search?q=widget&fragment=1", session).await;
Matt W736 assert_eq!(res.status(), StatusCode::OK);
Matt W737 let body = h.body(res).await;
Matt W738
Matt W739 // A fragment is a bare list, so it must not drag the page chrome with
Matt W740 // it — that would nest a whole document inside the overlay.
Matt W741 assert!(!body.contains("<html"), "the fragment must not be a full page: {body}");
Matt W742
Matt W743 assert!(!body.contains("widgetconfidential"), "private content in the palette: {body}");
Matt W744 assert!(!body.contains("widgetsecret"), "private repo name in the palette: {body}");
Matt W745 assert!(body.contains("widgetopen"), "the public repo should be findable");
Matt W746 }
Matt W747
Matt W748 let res = h.get_as("/search?q=widget&fragment=1", &alice_session).await;
Matt W749 let body = h.body(res).await;
Matt W750 assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
Matt W751
Matt W752 h.drop_schema().await;
Matt W753}
Matt W754
Matt W755/// A profile page must not enumerate repositories the viewer cannot open.
Matt W756#[tokio::test]
Matt W757async fn a_profile_does_not_list_private_repositories_to_strangers() {
Matt W758 let Some(h) = harness("profile").await else { return };
Matt W759
Matt W760 let (alice, alice_session) = user(&h.db, "alice", false).await;
Matt W761 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W762 // Distinctive names: `hidden` would also match the markup of every
Matt W763 // `<input type="hidden">` on the page and the assertion would pass or fail
Matt W764 // for the wrong reason.
Matt W765 repo(&h.db, alice, "unlistable-repo", true).await;
Matt W766 repo(&h.db, alice, "listable-repo", false).await;
Matt W767
Matt W768 for session in [None, Some(mallory.as_str())] {
Matt W769 let res = h.request("/alice", session).await;
Matt W770 assert_eq!(res.status(), StatusCode::OK);
Matt W771 let body = h.body(res).await;
Matt W772 assert!(
Matt W773 !body.contains("unlistable-repo"),
Matt W774 "private repo listed on a profile: {body}"
Matt W775 );
Matt W776 assert!(body.contains("listable-repo"));
Matt W777 }
Matt W778
Matt W779 let body = h.body(h.get_as("/alice", &alice_session).await).await;
Matt W780 assert!(
Matt W781 body.contains("unlistable-repo"),
Matt W782 "the owner must see their own repositories"
Matt W783 );
Matt W784
Matt W785 h.drop_schema().await;
Matt W786}
Matt W787
Matt W788/// A collaborator grant is what opens a private repository, and nothing else.
Matt W789#[tokio::test]
Matt W790async fn a_collaborator_grant_is_what_opens_a_private_repo() {
Matt W791 let Some(h) = harness("collaborator").await else { return };
Matt W792
Matt W793 let (alice, _) = user(&h.db, "alice", false).await;
Matt W794 let (bob, bob_session) = user(&h.db, "bob", false).await;
Matt W795 let hidden = repo(&h.db, alice, "hidden", true).await;
Matt W796
Matt W797 assert_eq!(
Matt W798 h.get_as("/alice/hidden", &bob_session).await.status(),
Matt W799 StatusCode::NOT_FOUND
Matt W800 );
Matt W801
Matt W802 sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
Matt W803 .bind(hidden)
Matt W804 .bind(bob)
Matt W805 .execute(&h.db)
Matt W806 .await
Matt W807 .unwrap();
Matt W808
Matt W809 assert_eq!(
Matt W810 h.get_as("/alice/hidden", &bob_session).await.status(),
Matt W811 StatusCode::OK,
Matt W812 "a read collaborator must be able to open it"
Matt W813 );
Matt W814
Matt W815 // …but read is not settings.
Matt W816 assert_eq!(
Matt W817 h.get_as("/alice/hidden/settings", &bob_session).await.status(),
Matt W818 StatusCode::FORBIDDEN,
Matt W819 "read access must not reach settings"
Matt W820 );
Matt W821
Matt W822 h.drop_schema().await;
Matt W823}
Matt W824
Matt W825/// > Enforce authorization in middleware before the handler runs.
Matt W826///
Matt W827/// A `read` collaborator sees a 403 on settings, not a 404 — they can already
Matt W828/// see the repository, so nothing is disclosed — while a stranger sees a 404.
Matt W829/// The distinction is the whole design of `AppError`.
Matt W830#[tokio::test]
Matt W831async fn a_403_is_only_ever_shown_to_somebody_who_can_already_see_the_repo() {
Matt W832 let Some(h) = harness("403_vs_404").await else { return };
Matt W833
Matt W834 let (alice, _) = user(&h.db, "alice", false).await;
Matt W835 let (bob, bob_session) = user(&h.db, "bob", false).await;
Matt W836 let (_, mallory) = user(&h.db, "mallory", false).await;
Matt W837 let hidden = repo(&h.db, alice, "hidden", true).await;
Matt W838
Matt W839 sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
Matt W840 .bind(hidden)
Matt W841 .bind(bob)
Matt W842 .execute(&h.db)
Matt W843 .await
Matt W844 .unwrap();
Matt W845
Matt W846 assert_eq!(
Matt W847 h.get_as("/alice/hidden/settings", &bob_session).await.status(),
Matt W848 StatusCode::FORBIDDEN
Matt W849 );
Matt W850 assert_eq!(
Matt W851 h.get_as("/alice/hidden/settings", &mallory).await.status(),
Matt W852 StatusCode::NOT_FOUND,
Matt W853 "a stranger must not learn the repository exists"
Matt W854 );
Matt W855
Matt W856 h.drop_schema().await;
Matt W857}
Matt W858
Matt W859/// > Strict CSP with no `unsafe-inline`, `nosniff`, and framing denied — on
Matt W860/// > every response, including errors.
Matt W861#[tokio::test]
Matt W862async fn security_headers_are_present_on_every_response() {
Matt W863 let Some(h) = harness("headers").await else { return };
Matt W864
Matt W865 for path in ["/", "/does-not-exist", "/login"] {
Matt W866 let res = h.get(path).await;
Matt W867 let headers = res.headers();
Matt W868
Matt W869 let csp = headers
Matt W870 .get("content-security-policy")
Matt W871 .and_then(|v| v.to_str().ok())
Matt W872 .unwrap_or_default()
Matt W873 .to_owned();
Matt W874
Matt W875 assert!(!csp.is_empty(), "{path} has no CSP");
Matt W876 assert!(!csp.contains("unsafe-inline"), "{path} CSP allows unsafe-inline: {csp}");
Matt W877 assert!(!csp.contains("unsafe-eval"), "{path} CSP allows unsafe-eval: {csp}");
Matt W878 assert!(csp.contains("frame-ancestors 'none'"), "{path}: {csp}");
Matt W879 assert!(csp.contains("object-src 'none'"), "{path}: {csp}");
Matt W880
Matt W881 assert_eq!(
Matt W882 headers.get("x-content-type-options").and_then(|v| v.to_str().ok()),
Matt W883 Some("nosniff"),
Matt W884 "{path}"
Matt W885 );
Matt W886 assert_eq!(
Matt W887 headers.get("x-frame-options").and_then(|v| v.to_str().ok()),
Matt W888 Some("DENY"),
Matt W889 "{path}"
Matt W890 );
Matt W891 }
Matt W892
Matt W893 h.drop_schema().await;
Matt W894}
Matt W895
Matt W896/// > `/metrics` — bind to loopback only.
Matt W897///
Matt W898/// Requests through this harness have no `ConnectInfo`, which is the same
Matt W899/// position a request arriving without a resolvable peer is in. The endpoint
Matt W900/// must refuse rather than default to serving.
Matt W901#[tokio::test]
Matt W902async fn metrics_are_not_served_without_a_loopback_peer() {
Matt W903 let Some(h) = harness("metrics").await else { return };
Matt W904
Matt W905 let res = h.get("/metrics").await;
Matt W906 assert_ne!(
Matt W907 res.status(),
Matt W908 StatusCode::OK,
Matt W909 "metrics must not be served to a request that cannot prove it is local"
Matt W910 );
Matt W911
Matt W912 h.drop_schema().await;
Matt W913}
Matt W914
Matt W915/// > CSRF: double-submit cookie, validated on every non-GET.
Matt W916#[tokio::test]
Matt W917async fn state_changing_requests_without_a_csrf_token_are_refused() {
Matt W918 let Some(h) = harness("csrf").await else { return };
Matt W919
Matt W920 let (alice, session) = user(&h.db, "alice", false).await;
Matt W921 repo(&h.db, alice, "r", false).await;
Matt W922
Matt W923 for (method, path) in [
Matt W924 ("POST", "/repos"),
Matt W925 ("POST", "/logout"),
Matt W926 ("POST", "/settings/keys"),
Matt W927 ("POST", "/alice/r/settings/general"),
Matt W928 ("POST", "/alice/r/issues"),
Matt W929 ] {
Matt W930 let req = Request::builder()
Matt W931 .uri(path)
Matt W932 .method(method)
Matt W933 .header("cookie", format!("{}={session}", df_auth::session::COOKIE_NAME))
Matt W934 .header("content-type", "application/x-www-form-urlencoded")
Matt W935 .body(Body::from("name=x"))
Matt W936 .unwrap();
Matt W937
Matt W938 let res = h.app.clone().oneshot(req).await.unwrap();
Matt W939 assert_eq!(
Matt W940 res.status(),
Matt W941 StatusCode::FORBIDDEN,
Matt W942 "{method} {path} was accepted without a CSRF token"
Matt W943 );
Matt W944 }
Matt W945
Matt W946 h.drop_schema().await;
Matt W947}
Matt W948
Matt W949/// > Blob content served with `Content-Disposition: attachment` and
Matt W950/// > `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on
Matt W951/// > the app origin.
Matt W952///
Matt W953/// The store here cannot produce a blob, so this asserts the reachable part:
Matt W954/// that the raw route never answers with a content type a browser would render.
Matt W955/// The header construction itself is pinned by a unit test next to the handler.
Matt W956#[tokio::test]
Matt W957async fn the_raw_route_never_serves_a_renderable_content_type() {
Matt W958 let Some(h) = harness("raw").await else { return };
Matt W959
Matt W960 let (alice, _) = user(&h.db, "alice", false).await;
Matt W961 repo(&h.db, alice, "r", false).await;
Matt W962
Matt W963 let res = h.get("/alice/r/raw/main/evil.html").await;
Matt W964 let ct = res
Matt W965 .headers()
Matt W966 .get("content-type")
Matt W967 .and_then(|v| v.to_str().ok())
Matt W968 .unwrap_or_default()
Matt W969 .to_owned();
Matt W970 assert!(
Matt W971 !ct.contains("text/html") || res.status() != StatusCode::OK,
Matt W972 "raw served renderable HTML: {ct}"
Matt W973 );
Matt W974
Matt W975 h.drop_schema().await;
Matt W976}
Matt W977
Matt W978/// Path traversal must be refused at the parser, before any handler.
Matt W979#[tokio::test]
Matt W980async fn traversal_and_control_characters_are_refused() {
Matt W981 let Some(h) = harness("traversal").await else { return };
Matt W982
Matt W983 let (alice, _) = user(&h.db, "alice", false).await;
Matt W984 repo(&h.db, alice, "r", false).await;
Matt W985
Matt W986 for path in [
Matt W987 "/alice/r/blob/main/../../../../etc/passwd",
Matt W988 "/alice/r/blob/main/%2e%2e%2f%2e%2e%2fetc%2fpasswd",
Matt W989 "/alice/r/raw/main/%00etc/passwd",
Matt W990 "/alice/r/tree/main/../..",
Matt W991 ] {
Matt W992 let res = h.get(path).await;
Matt W993 assert!(
Matt W994 res.status().is_client_error(),
Matt W995 "{path} returned {} instead of a client error",
Matt W996 res.status()
Matt W997 );
Matt W998 }
Matt W999
Matt W1000 h.drop_schema().await;
Matt W1001}
Matt W1002
Matt W1003/// A signed-out visitor must not be able to act, only to look.
Matt W1004#[tokio::test]
Matt W1005async fn anonymous_visitors_cannot_reach_authenticated_pages() {
Matt W1006 let Some(h) = harness("anon").await else { return };
Matt W1007
Matt W1008 for path in ["/settings", "/new", "/orgs/new"] {
Matt W1009 let res = h.get(path).await;
Matt W1010 assert_eq!(
Matt W1011 res.status(),
Matt W1012 StatusCode::SEE_OTHER,
Matt W1013 "{path} should redirect an anonymous visitor to sign in"
Matt W1014 );
Matt W1015 assert_eq!(
Matt W1016 res.headers().get("location").and_then(|v| v.to_str().ok()),
Matt W1017 Some("/login"),
Matt W1018 "{path}"
Matt W1019 );
Matt W1020 }
Matt W1021
Matt W1022 h.drop_schema().await;
Matt W1023}
Matt W1024
Matt W1025/// The session cookie is a bearer token, and the row id is not a credential.
Matt W1026///
Matt W1027/// The cookie used to be `sessions.id`, a UUIDv7 — time-ordered, with a counter
Matt W1028/// that only reseeds once a millisecond, so ids minted together share their
Matt W1029/// leading bits and any other id from the same generator narrows the rest. The
Matt W1030/// id is still the primary key; what this pins is that presenting it no longer
Matt W1031/// signs anybody in.
Matt W1032#[tokio::test]
Matt W1033async fn a_session_row_id_is_not_a_credential() {
Matt W1034 let Some(h) = harness("sessionid").await else { return };
Matt W1035
Matt W1036 let (alice, token) = user(&h.db, "alice", false).await;
Matt W1037 repo(&h.db, alice, "hidden", true).await;
Matt W1038
Matt W1039 // The token works.
Matt W1040 assert_eq!(
Matt W1041 h.get_as("/alice/hidden", &token).await.status(),
Matt W1042 StatusCode::OK,
Matt W1043 "the session token must sign alice in"
Matt W1044 );
Matt W1045
Matt W1046 // The row id, which is what the old cookie carried, does not.
Matt W1047 let id: Uuid = sqlx::query_scalar("SELECT id FROM sessions WHERE user_id = $1")
Matt W1048 .bind(alice)
Matt W1049 .fetch_one(&h.db)
Matt W1050 .await
Matt W1051 .expect("the seeded session");
Matt W1052 assert_eq!(
Matt W1053 h.get_as("/alice/hidden", &id.to_string()).await.status(),
Matt W1054 StatusCode::NOT_FOUND,
Matt W1055 "a session id must not authenticate — it is an identifier, not a secret"
Matt W1056 );
Matt W1057
Matt W1058 // Neither does the stored hash, which is what a database leak would yield.
Matt W1059 let hash: String = sqlx::query_scalar("SELECT token_hash FROM sessions WHERE user_id = $1")
Matt W1060 .bind(alice)
Matt W1061 .fetch_one(&h.db)
Matt W1062 .await
Matt W1063 .expect("the seeded session");
Matt W1064 assert_ne!(hash, token, "the plaintext token must not be stored");
Matt W1065 assert_eq!(
Matt W1066 h.get_as("/alice/hidden", &hash).await.status(),
Matt W1067 StatusCode::NOT_FOUND,
Matt W1068 "the stored hash must not be replayable as the token"
Matt W1069 );
Matt W1070
Matt W1071 h.drop_schema().await;
Matt W1072}
Matt W1073
Matt W1074/// An archived repository is read-only. The settings page promises that in so
Matt W1075/// many words, so it has to be true on the wire — enforcing it only in the UI
Matt W1076/// would leave the promise false for every Git client.
Matt W1077#[tokio::test]
Matt W1078async fn an_archived_repository_refuses_pushes() {
Matt W1079 let Some(h) = harness("archived").await else { return };
Matt W1080
Matt W1081 let (alice, _) = user(&h.db, "alice", false).await;
Matt W1082 let repo_id = repo(&h.db, alice, "frozen", false).await;
Matt W1083 sqlx::query("UPDATE repos SET archived = true WHERE id = $1")
Matt W1084 .bind(repo_id)
Matt W1085 .execute(&h.db)
Matt W1086 .await
Matt W1087 .unwrap();
Matt W1088
Matt W1089 let token = df_auth::tokens::create(&h.db, alice, "push", &[], None)
Matt W1090 .await
Matt W1091 .expect("minting a token");
Matt W1092 let basic = base64::Engine::encode(
Matt W1093 &base64::engine::general_purpose::STANDARD,
Matt W1094 format!("alice:{}", token.plaintext),
Matt W1095 );
Matt W1096
Matt W1097 let req = Request::builder()
Matt W1098 .uri("/alice/frozen/git-receive-pack")
Matt W1099 .method("POST")
Matt W1100 .header("authorization", format!("Basic {basic}"))
Matt W1101 .header("content-type", "application/x-git-receive-pack-request")
Matt W1102 .body(Body::from("0000"))
Matt W1103 .unwrap();
Matt W1104
Matt W1105 let res = h.app.clone().oneshot(req).await.unwrap();
Matt W1106 assert_eq!(
Matt W1107 res.status(),
Matt W1108 StatusCode::BAD_REQUEST,
Matt W1109 "an archived repository accepted a push"
Matt W1110 );
Matt W1111
Matt W1112 // …and reads still work, or "archived" would just mean "deleted".
Matt W1113 assert_eq!(h.get("/alice/frozen").await.status(), StatusCode::OK);
Matt W1114
Matt W1115 h.drop_schema().await;
Matt W1116}
Matt W1117
Matt W1118/// A push to a repository the token's owner cannot write must be refused even
Matt W1119/// though the credentials are valid.
Matt W1120#[tokio::test]
Matt W1121async fn a_valid_token_does_not_grant_push_to_a_repo_you_cannot_write() {
Matt W1122 let Some(h) = harness("push_authz").await else { return };
Matt W1123
Matt W1124 let (alice, _) = user(&h.db, "alice", false).await;
Matt W1125 let (mallory, _) = user(&h.db, "mallory", false).await;
Matt W1126 repo(&h.db, alice, "theirs", true).await;
Matt W1127
Matt W1128 let token = df_auth::tokens::create(&h.db, mallory, "push", &[], None)
Matt W1129 .await
Matt W1130 .expect("minting a token");
Matt W1131 let basic = base64::Engine::encode(
Matt W1132 &base64::engine::general_purpose::STANDARD,
Matt W1133 format!("mallory:{}", token.plaintext),
Matt W1134 );
Matt W1135
Matt W1136 let req = Request::builder()
Matt W1137 .uri("/alice/theirs/git-receive-pack")
Matt W1138 .method("POST")
Matt W1139 .header("authorization", format!("Basic {basic}"))
Matt W1140 .body(Body::from("0000"))
Matt W1141 .unwrap();
Matt W1142
Matt W1143 let res = h.app.clone().oneshot(req).await.unwrap();
Matt W1144 assert_eq!(
Matt W1145 res.status(),
Matt W1146 StatusCode::NOT_FOUND,
Matt W1147 "a private repo must stay a 404 to an authenticated stranger, even on the push path"
Matt W1148 );
Matt W1149
Matt W1150 h.drop_schema().await;
Matt W1151}

1151 lines · Rust