Jump to…
snowfix: 500 error because of database is patchedyuzpxzopsouq1mo
1//! The §9 security checklist, one test per item (M6).
2//!
3//! > **Private repo leakage.** The most common forge vulnerability class. […]
4//! > Deliberately test: private repo object access via a public fork's URL, blob
5//! > access by OID without a reachable ref, change and issue numbers enumerated
6//! > across visibility boundaries, and search results crossing repos. Every one
7//! > of these has been a real CVE in a real forge.
8//!
9//! These drive the **real router** — every middleware, every extractor, every
10//! handler — through `tower::ServiceExt::oneshot`. Testing the SQL directly
11//! would miss exactly the bugs that matter here, which live in the seam between
12//! a handler and the authorization it forgot to call.
13//!
14//! They need a throwaway Postgres. Set `DF_TEST_DATABASE_URL`; without it they
15//! skip with a warning, and `DF_REQUIRE_DB=1` turns that skip into a failure so
16//! CI cannot quietly stop running them.
17//!
18//! ```sh
19//! docker run -d --name df-pgtest -e POSTGRES_PASSWORD=x -e POSTGRES_DB=t \
20//! -p 55432:5432 postgres:17-alpine
21//! DF_TEST_DATABASE_URL=postgres://postgres:x@127.0.0.1:55432/t \
22//! cargo test -p df-web security
23//! ```
24//!
25//! Each test runs in its own Postgres **schema**, created and dropped around it,
26//! so they neither collide nor need ordering.
27
28#![cfg(test)]
29
30use std::path::Path;
31use std::sync::Arc;
32
33use axum::body::Body;
34use axum::http::{Request, StatusCode};
35use axum::response::Response;
36use df_store::{
37 BlameLine, Blob, Bookmark, ConflictedFile, Diff, DiffOpts, EditOutcome, MergeOutcome, RepoId,
38 RepoStore, Result as SResult, RevId, Revision, Signature, StoreError, TreeEntry,
39};
40use sqlx::{Executor, PgPool};
41use tower::ServiceExt;
42use uuid::Uuid;
43
44use crate::config::Config;
45use crate::state::{AppState, Inner};
46
47// ─── harness ─────────────────────────────────────────────────────────────────
48
49/// A store that has nothing in it.
50///
51/// The visibility tests are about *authorization*, and authorization must be
52/// decided before storage is touched. A store that refuses everything makes that
53/// falsifiable: if a handler ever reaches storage on an unauthorized request,
54/// the response changes from 404 to 500 and the test fails.
55struct EmptyStore;
56
57#[async_trait::async_trait]
58impl RepoStore for EmptyStore {
59 async fn create(&self, _: RepoId, _: &str) -> SResult<()> {
60 Ok(())
61 }
62 async fn configure_receive_validation(&self, _: RepoId, _: &str) -> SResult<()> {
63 Ok(())
64 }
65 async fn delete(&self, _: RepoId) -> SResult<()> {
66 Ok(())
67 }
68 async fn exists(&self, _: RepoId) -> bool {
69 true
70 }
71 async fn is_empty(&self, _: RepoId) -> SResult<bool> {
72 Ok(true)
73 }
74 async fn list_tree(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<TreeEntry>> {
75 Err(StoreError::NoSuchRevision)
76 }
77 async fn read_blob(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Blob> {
78 Err(StoreError::NoSuchRevision)
79 }
80 async fn diff(&self, _: RepoId, _: &RevId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
81 Err(StoreError::NoSuchRevision)
82 }
83 async fn diff_from_parent(&self, _: RepoId, _: &RevId, _: DiffOpts) -> SResult<Diff> {
84 Err(StoreError::NoSuchRevision)
85 }
86 async fn log(&self, _: RepoId, _: &RevId, _: usize) -> SResult<Vec<Revision>> {
87 Err(StoreError::NoSuchRevision)
88 }
89 async fn revision(&self, _: RepoId, _: &RevId) -> SResult<Revision> {
90 Err(StoreError::NoSuchRevision)
91 }
92 async fn bookmarks(&self, _: RepoId) -> SResult<Vec<Bookmark>> {
93 Ok(vec![])
94 }
95 async fn merge_base(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<Option<RevId>> {
96 Ok(None)
97 }
98 async fn is_ancestor(&self, _: RepoId, _: &RevId, _: &RevId) -> SResult<bool> {
99 Ok(false)
100 }
101 async fn resolve(&self, _: RepoId, _: &str) -> SResult<RevId> {
102 Err(StoreError::NoSuchRevision)
103 }
104 async fn merge(
105 &self,
106 _: RepoId,
107 _: &str,
108 _: &RevId,
109 _: &str,
110 _: &Signature,
111 ) -> SResult<MergeOutcome> {
112 Err(StoreError::NoSuchRepo)
113 }
114 async fn commit_file(
115 &self,
116 _: RepoId,
117 _: &str,
118 _: &RevId,
119 _: &str,
120 _: Vec<u8>,
121 _: &str,
122 _: &Signature,
123 ) -> SResult<EditOutcome> {
124 Err(StoreError::NoSuchRepo)
125 }
126 async fn conflicts(&self, _: RepoId, _: &RevId) -> SResult<Vec<ConflictedFile>> {
127 Ok(vec![])
128 }
129 async fn size_bytes(&self, _: RepoId) -> SResult<u64> {
130 Ok(0)
131 }
132 async fn blame(&self, _: RepoId, _: &RevId, _: &Path) -> SResult<Vec<BlameLine>> {
133 Err(StoreError::NoSuchRevision)
134 }
135 async fn last_commit_for_path(
136 &self,
137 _: RepoId,
138 _: &RevId,
139 _: &Path,
140 ) -> SResult<Option<Revision>> {
141 Ok(None)
142 }
143 async fn last_commits_in_dir(
144 &self,
145 _: RepoId,
146 _: &RevId,
147 _: &Path,
148 _: &[String],
149 ) -> SResult<std::collections::HashMap<String, Revision>> {
150 Ok(std::collections::HashMap::new())
151 }
152 async fn diff_stats(&self, _: RepoId, revs: &[RevId]) -> SResult<Vec<Option<(usize, usize)>>> {
153 Ok(vec![None; revs.len()])
154 }
155}
156
157/// Enough provider metadata for `Oidc` to construct without a network call.
158const METADATA: &str = r#"{
159 "issuer": "https://oidc.test/",
160 "authorization_endpoint": "https://oidc.test/auth",
161 "token_endpoint": "https://oidc.test/token",
162 "jwks_uri": "https://oidc.test/jwks",
163 "response_types_supported": ["code"],
164 "subject_types_supported": ["public"],
165 "id_token_signing_alg_values_supported": ["RS256"]
166}"#;
167
168struct Harness {
169 app: axum::Router,
170 db: PgPool,
171 schema: String,
172}
173
174impl Harness {
175 /// A GET as an anonymous visitor.
176 async fn get(&self, path: &str) -> Response {
177 self.request(path, None).await
178 }
179
180 /// A GET as a signed-in user.
181 async fn get_as(&self, path: &str, session: &str) -> Response {
182 self.request(path, Some(session)).await
183 }
184
185 async fn request(&self, path: &str, session: Option<&str>) -> Response {
186 let mut req = Request::builder().uri(path).method("GET");
187 if let Some(s) = session {
188 req = req.header("cookie", format!("{}={s}", df_auth::session::COOKIE_NAME));
189 }
190 self.app
191 .clone()
192 .oneshot(req.body(Body::empty()).expect("request"))
193 .await
194 .expect("response")
195 }
196
197 async fn body(&self, res: Response) -> String {
198 let bytes = axum::body::to_bytes(res.into_body(), 4 * 1024 * 1024)
199 .await
200 .expect("body");
201 String::from_utf8_lossy(&bytes).into_owned()
202 }
203
204 async fn drop_schema(&self) {
205 let _ = self
206 .db
207 .execute(format!("DROP SCHEMA IF EXISTS {} CASCADE", self.schema).as_str())
208 .await;
209 }
210}
211
212/// Send `tracing` output to the test's captured stderr, once per process.
213///
214/// Without this an internal error is a bare 500 in the assertion and the reason
215/// is thrown away — which is exactly the information a failing security test
216/// needs to hand over.
217fn init_tracing() {
218 use std::sync::Once;
219 static ONCE: Once = Once::new();
220 ONCE.call_once(|| {
221 let _ = tracing_subscriber::fmt()
222 .with_env_filter(
223 tracing_subscriber::EnvFilter::try_from_default_env()
224 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("df_web=error")),
225 )
226 .with_test_writer()
227 .try_init();
228 });
229}
230
231/// Build a harness, or `None` when no test database is configured.
232async fn harness(name: &str) -> Option<Harness> {
233 init_tracing();
234
235 let Ok(url) = std::env::var("DF_TEST_DATABASE_URL") else {
236 if std::env::var_os("DF_REQUIRE_DB").is_some() {
237 panic!("DF_REQUIRE_DB is set but DF_TEST_DATABASE_URL is not");
238 }
239 eprintln!("warning: DF_TEST_DATABASE_URL unset, security test `{name}` skipped");
240 return None;
241 };
242
243 // One schema per test, named after the test. Deriving it from the name
244 // rather than from a random id makes collisions impossible — two tests
245 // cannot share a name — so they stay independent while running in parallel.
246 let schema: String = format!("df_test_{name}")
247 .chars()
248 .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
249 .take(60)
250 .collect();
251
252 let db = sqlx::postgres::PgPoolOptions::new()
253 .max_connections(4)
254 .after_connect({
255 let schema = schema.clone();
256 move |conn, _| {
257 let schema = schema.clone();
258 Box::pin(async move {
259 conn.execute(format!("SET search_path TO {schema}, public").as_str())
260 .await?;
261 Ok(())
262 })
263 }
264 })
265 .connect(&url)
266 .await
267 .expect("connecting to DF_TEST_DATABASE_URL");
268
269 // Dropped first: a previous run that panicked before its cleanup would
270 // otherwise leave rows behind and make this run fail for the wrong reason.
271 db.execute(format!("DROP SCHEMA IF EXISTS {schema} CASCADE").as_str())
272 .await
273 .expect("dropping any leftover test schema");
274 db.execute(format!("CREATE SCHEMA {schema}").as_str())
275 .await
276 .expect("creating the test schema");
277
278 // Extensions are database-wide, and `CREATE EXTENSION` installs into the
279 // first schema on the search path. Pinning them to `public` up front stops
280 // the first test that runs from installing them into its own schema and
281 // taking them away when it drops it — the `IF NOT EXISTS` in the migration
282 // then correctly does nothing.
283 // `CREATE EXTENSION IF NOT EXISTS` is not safe to run concurrently: the
284 // existence check and the insert are not atomic, so test binaries running
285 // in parallel against a *fresh* database collide on `pg_extension`'s unique
286 // index and fail here. An advisory lock serialises just this step. Only the
287 // first run on a new database ever contends — which is exactly the run
288 // where the failure is most confusing, because it looks like the security
289 // tests themselves are broken.
290 const EXTENSION_LOCK: i64 = 0x6466_5F65_7874; // "df_ext"
291
292 let mut conn = db.acquire().await.expect("a connection for extension setup");
293
294 sqlx::query("SELECT pg_advisory_lock($1)")
295 .bind(EXTENSION_LOCK)
296 .execute(&mut *conn)
297 .await
298 .expect("taking the extension lock");
299
300 let installed = async {
301 for ext in ["citext", "pg_trgm"] {
302 conn.execute(format!("CREATE EXTENSION IF NOT EXISTS {ext} SCHEMA public").as_str())
303 .await?;
304 }
305 Ok::<_, sqlx::Error>(())
306 }
307 .await;
308
309 // Released whether or not the install worked, so a failure here does not
310 // wedge every other test binary waiting on the lock.
311 sqlx::query("SELECT pg_advisory_unlock($1)")
312 .bind(EXTENSION_LOCK)
313 .execute(&mut *conn)
314 .await
315 .expect("releasing the extension lock");
316
317 installed.expect("installing an extension into public");
318 drop(conn);
319
320 df_db::migrate(&db).await.expect("migrations");
321
322 // The schema on the search path must be the one that owns the tables. If a
323 // previous run ever migrated into `public`, every query here would silently
324 // read *that* schema instead — the tests would still pass, against the wrong
325 // data, until a new migration made them disagree. Caught here rather than
326 // three layers down in a handler.
327 let owned: Option<String> = sqlx::query_scalar(&format!(
328 "SELECT to_regclass('{schema}.repos')::text"
329 ))
330 .fetch_one(&db)
331 .await
332 .expect("checking schema ownership");
333 assert!(
334 owned.is_some(),
335 "migrations did not land in {schema}; is there a stale dogfood schema in `public`? \
336 Reset it with: DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
337 );
338
339 let config = Config::for_tests();
340 let oidc = df_auth::Oidc::from_metadata_json(
341 METADATA,
342 "test-client",
343 "test-secret",
344 "https://dogfood.test/auth/callback",
345 "openid profile email",
346 )
347 .expect("test oidc client");
348
349 let state = AppState(Arc::new(Inner {
350 db: db.clone(),
351 oidc,
352 config,
353 store: Arc::new(EmptyStore),
354 limiter: crate::ratelimit::Limiter::new(),
355 }));
356
357 Some(Harness {
358 app: crate::build_router(state),
359 db,
360 schema,
361 })
362}
363
364/// Seed a user and an active session, returning `(user_id, session_token)`.
365///
366/// The second element is the cookie value, which is the token and not the row
367/// id — the id is never accepted as a credential.
368async fn user(db: &PgPool, handle: &str, admin: bool) -> (Uuid, String) {
369 let id = Uuid::now_v7();
370 sqlx::query(
371 "INSERT INTO users (id, subject, handle, display_name, is_admin)
372 VALUES ($1, $2, $3, $3, $4)",
373 )
374 .bind(id)
375 .bind(format!("sub-{handle}"))
376 .bind(handle)
377 .bind(admin)
378 .execute(db)
379 .await
380 .expect("seeding a user");
381
382 // Minted the same way a real login does, so the tests exercise the token
383 // path rather than a shape only the tests produce.
384 let token = df_auth::session::generate_token();
385 sqlx::query(
386 "INSERT INTO sessions (id, user_id, token_hash, expires_at)
387 VALUES ($1, $2, encode(sha256($3::bytea), 'hex'), now() + '14 days')",
388 )
389 .bind(Uuid::now_v7())
390 .bind(id)
391 .bind(token.as_bytes())
392 .execute(db)
393 .await
394 .expect("seeding a session");
395
396 (id, token)
397}
398
399/// Seed a repository owned by `owner`, returning its id.
400async fn repo(db: &PgPool, owner: Uuid, name: &str, private: bool) -> Uuid {
401 let id = Uuid::now_v7();
402 sqlx::query(
403 "INSERT INTO repos (id, owner_kind, owner_user_id, name, visibility, default_bookmark)
404 VALUES ($1, 'user', $2, $3, $4::visibility, 'main')",
405 )
406 .bind(id)
407 .bind(owner)
408 .bind(name)
409 .bind(if private { "private" } else { "public" })
410 .execute(db)
411 .await
412 .expect("seeding a repo");
413
414 sqlx::query("INSERT INTO repo_counters (repo_id) VALUES ($1)")
415 .bind(id)
416 .execute(db)
417 .await
418 .expect("seeding counters");
419
420 id
421}
422
423/// Seed a change with one revision.
424async fn change(db: &PgPool, repo_id: Uuid, number: i64, change_id: &str, title: &str) -> Uuid {
425 let id = Uuid::now_v7();
426 sqlx::query(
427 "INSERT INTO changes (id, repo_id, change_id, number, title, description,
428 state, target_bookmark)
429 VALUES ($1, $2, $3, $4, $5, 'secret description', 'open', 'main')",
430 )
431 .bind(id)
432 .bind(repo_id)
433 .bind(change_id)
434 .bind(number)
435 .bind(title)
436 .execute(db)
437 .await
438 .expect("seeding a change");
439
440 sqlx::query(
441 "INSERT INTO revisions (id, change_id_fk, rev, seq, author_name, author_email,
442 authored_at, message)
443 VALUES ($1, $2, $3, 1, 'A', 'a@b.c', now(), $4)",
444 )
445 .bind(Uuid::now_v7())
446 .bind(id)
447 .bind(format!("{:040x}", number))
448 .bind(title)
449 .execute(db)
450 .await
451 .expect("seeding a revision");
452
453 id
454}
455
456async fn issue(db: &PgPool, repo_id: Uuid, number: i64, title: &str) -> Uuid {
457 let id = Uuid::now_v7();
458 sqlx::query(
459 "INSERT INTO issues (id, repo_id, number, title, body)
460 VALUES ($1, $2, $3, $4, 'secret issue body')",
461 )
462 .bind(id)
463 .bind(repo_id)
464 .bind(number)
465 .bind(title)
466 .execute(db)
467 .await
468 .expect("seeding an issue");
469 id
470}
471
472// ─── the checklist ───────────────────────────────────────────────────────────
473
474/// > Return an identical 404 for a private repo and a nonexistent repo.
475#[tokio::test]
476async fn a_private_repo_and_a_nonexistent_repo_are_indistinguishable() {
477 let Some(h) = harness("identical_404").await else { return };
478
479 let (alice, _) = user(&h.db, "alice", false).await;
480 // A name that cannot appear in the chrome for any other reason — `hidden`
481 // would also match `aria-hidden` and `<input type="hidden">`, and the
482 // assertion would then fail for a reason that has nothing to do with the
483 // property being tested.
484 repo(&h.db, alice, "unmentionable-repo", true).await;
485
486 let private = h.get("/alice/unmentionable-repo").await;
487 let missing = h.get("/alice/does-not-exist").await;
488
489 assert_eq!(private.status(), StatusCode::NOT_FOUND);
490 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
491
492 let a = h.body(private).await;
493 let b = h.body(missing).await;
494 assert_eq!(a, b, "the two responses must be byte-identical");
495 assert!(
496 !a.contains("unmentionable-repo"),
497 "the repository name leaked into the 404"
498 );
499
500 h.drop_schema().await;
501}
502
503/// > Being authenticated is not authorization.
504#[tokio::test]
505async fn a_signed_in_stranger_cannot_reach_a_private_repo() {
506 let Some(h) = harness("stranger").await else { return };
507
508 let (alice, _) = user(&h.db, "alice", false).await;
509 let (_, mallory_session) = user(&h.db, "mallory", false).await;
510 repo(&h.db, alice, "hidden", true).await;
511
512 for path in [
513 "/alice/hidden",
514 "/alice/hidden/log",
515 "/alice/hidden/bookmarks",
516 "/alice/hidden/changes",
517 "/alice/hidden/issues",
518 "/alice/hidden/settings",
519 "/alice/hidden/tree/main/",
520 "/alice/hidden/blob/main/README.md",
521 "/alice/hidden/raw/main/README.md",
522 ] {
523 let res = h.get_as(path, &mallory_session).await;
524 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
525 }
526
527 h.drop_schema().await;
528}
529
530/// > Change and issue numbers enumerated across visibility boundaries.
531#[tokio::test]
532async fn change_and_issue_numbers_cannot_be_enumerated_across_a_boundary() {
533 let Some(h) = harness("enumeration").await else { return };
534
535 let (alice, _) = user(&h.db, "alice", false).await;
536 let (_, mallory) = user(&h.db, "mallory", false).await;
537
538 let hidden = repo(&h.db, alice, "hidden", true).await;
539 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret change title").await;
540 issue(&h.db, hidden, 1, "secret issue title").await;
541
542 for path in [
543 "/alice/hidden/changes/1",
544 "/alice/hidden/changes/klxqnvpq",
545 "/alice/hidden/changes/1/files",
546 "/alice/hidden/changes/1/revisions",
547 "/alice/hidden/changes/1/conflicts",
548 "/alice/hidden/issues/1",
549 "/alice/hidden/stacks/klxqnvpqlnlvtkmuqmtmxktlnvnomvwv",
550 ] {
551 for session in [None, Some(mallory.as_str())] {
552 let res = h.request(path, session).await;
553 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path} was reachable");
554 let body = h.body(res).await;
555 assert!(!body.contains("secret"), "{path} leaked content: {body}");
556 }
557 }
558
559 h.drop_schema().await;
560}
561
562/// > Blob access by OID without a reachable ref.
563///
564/// The route requires a repository the viewer can read *before* it resolves any
565/// revision, so a raw object id is not a way around visibility. The store used
566/// here refuses every read, which is what proves the 404 came from
567/// authorization rather than from a missing object.
568#[tokio::test]
569async fn a_raw_object_id_is_not_a_way_around_visibility() {
570 let Some(h) = harness("blob_by_oid").await else { return };
571
572 let (alice, _) = user(&h.db, "alice", false).await;
573 repo(&h.db, alice, "hidden", true).await;
574
575 let oid = "0123456789abcdef0123456789abcdef01234567";
576 for path in [
577 format!("/alice/hidden/blob/{oid}/src/secret.rs"),
578 format!("/alice/hidden/raw/{oid}/src/secret.rs"),
579 format!("/alice/hidden/tree/{oid}/"),
580 format!("/alice/hidden/log?rev={oid}"),
581 ] {
582 let res = h.get(&path).await;
583 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
584 }
585
586 h.drop_schema().await;
587}
588
589/// > Private repo object access via a public fork's URL.
590///
591/// Decided §13.5: forks get separate storage, no Git alternates. So a fork's URL
592/// resolves to the fork's own repository row and its own storage — the parent's
593/// visibility is irrelevant because the parent's objects are not there at all.
594/// This pins that the fork relationship grants nothing.
595#[tokio::test]
596async fn a_public_fork_does_not_expose_its_private_parent() {
597 let Some(h) = harness("fork").await else { return };
598
599 let (alice, _) = user(&h.db, "alice", false).await;
600 let (bob, _) = user(&h.db, "bob", false).await;
601 let (_, mallory) = user(&h.db, "mallory", false).await;
602
603 let parent = repo(&h.db, alice, "private-parent", true).await;
604 let fork = repo(&h.db, bob, "public-fork", false).await;
605 sqlx::query("UPDATE repos SET fork_of_repo_id = $2 WHERE id = $1")
606 .bind(fork)
607 .bind(parent)
608 .execute(&h.db)
609 .await
610 .unwrap();
611
612 // Content that exists only in the parent.
613 change(&h.db, parent, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "secret parent change").await;
614 issue(&h.db, parent, 1, "secret parent issue").await;
615
616 // The fork is public and readable…
617 assert_eq!(h.get("/bob/public-fork").await.status(), StatusCode::OK);
618
619 // …but the parent's content is not reachable through it, by number or by
620 // change id, and the parent itself stays a 404.
621 for path in [
622 "/bob/public-fork/changes/1",
623 "/bob/public-fork/changes/klxqnvpq",
624 "/bob/public-fork/issues/1",
625 "/alice/private-parent",
626 ] {
627 for session in [None, Some(mallory.as_str())] {
628 let res = h.request(path, session).await;
629 assert_eq!(res.status(), StatusCode::NOT_FOUND, "{path}");
630 let body = h.body(res).await;
631 assert!(!body.contains("secret parent"), "{path} leaked: {body}");
632 }
633 }
634
635 h.drop_schema().await;
636}
637
638/// > Search results crossing repos.
639#[tokio::test]
640async fn search_never_returns_anything_the_viewer_cannot_open() {
641 let Some(h) = harness("search").await else { return };
642
643 let (alice, alice_session) = user(&h.db, "alice", false).await;
644 let (_, mallory) = user(&h.db, "mallory", false).await;
645
646 // The last term of a query is a *prefix* match, so both names must start
647 // with the term for the search to be a fair test of visibility rather than
648 // of tokenisation.
649 let hidden = repo(&h.db, alice, "widgetsecret", true).await;
650 let public = repo(&h.db, alice, "widgetopen", false).await;
651
652 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
653 issue(&h.db, hidden, 1, "widgetconfidential issue").await;
654 change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
655
656 for session in [None, Some(mallory.as_str())] {
657 let res = h.request("/search?q=widget", session).await;
658 assert_eq!(res.status(), StatusCode::OK);
659 let body = h.body(res).await;
660
661 assert!(!body.contains("widgetconfidential"), "private content in search: {body}");
662 assert!(!body.contains("widgetsecret"), "private repo name in search: {body}");
663 assert!(body.contains("widgetopen"), "the public repo should be findable");
664 }
665
666 // The owner does see their own private content, or the feature is useless.
667 let res = h.get_as("/search?q=widget", &alice_session).await;
668 let body = h.body(res).await;
669 assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
670
671 h.drop_schema().await;
672}
673
674/// The redesign added several aggregate queries that only run at request time —
675/// the sub-bar counts, the filter-tab counts, and the weekly statistics. A
676/// mistake in any of them is a 500 on the two most-visited pages in a
677/// repository, and nothing else in the suite would catch it.
678///
679/// Not a security property, but it lives here because this is the only
680/// harness that can serve a real request against a real schema.
681#[tokio::test]
682async fn the_repository_pages_render_with_their_real_counts() {
683 let Some(h) = harness("counts").await else { return };
684
685 let (alice, session) = user(&h.db, "alice", false).await;
686 let r = repo(&h.db, alice, "counted", false).await;
687
688 change(&h.db, r, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "first change").await;
689 change(&h.db, r, 2, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "second change").await;
690 issue(&h.db, r, 1, "an issue").await;
691
692 for path in ["/alice/counted/changes", "/alice/counted/issues", "/alice/counted/bookmarks"] {
693 let res = h.get_as(path, &session).await;
694 assert_eq!(res.status(), StatusCode::OK, "{path} did not render");
695 let body = h.body(res).await;
696
697 // The sub-bar states the repository's vital signs on every page, so
698 // its numbers are the ones that must be right everywhere.
699 assert!(
700 body.contains("2 open"),
701 "{path} should report two open changes in the sub-bar: {body}"
702 );
703 }
704
705 // The change list additionally runs the tab counts and the weekly
706 // aggregate, including a percentile over an empty set — which returns
707 // NULL, and must decode as `None` rather than failing the query.
708 let res = h.get_as("/alice/counted/changes", &session).await;
709 let body = h.body(res).await;
710 assert!(body.contains("second change"), "the list should show its changes: {body}");
711 assert!(body.contains("Median time to first review"), "week stats missing: {body}");
712
713 h.drop_schema().await;
714}
715
716/// The ⌘K palette is a second entry point into search, and a second entry
717/// point is exactly where a visibility rule gets forgotten.
718///
719/// It must not be: the palette renders through the same handler in fragment
720/// mode, so this asserts the property directly rather than trusting that it is
721/// the same code path.
722#[tokio::test]
723async fn the_palette_fragment_obeys_the_same_visibility_rule_as_search() {
724 let Some(h) = harness("palette").await else { return };
725
726 let (alice, alice_session) = user(&h.db, "alice", false).await;
727 let (_, mallory) = user(&h.db, "mallory", false).await;
728
729 let hidden = repo(&h.db, alice, "widgetsecret", true).await;
730 let public = repo(&h.db, alice, "widgetopen", false).await;
731 change(&h.db, hidden, 1, "klxqnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetconfidential change").await;
732 change(&h.db, public, 1, "mmmmnvpqlnlvtkmuqmtmxktlnvnomvwv", "widgetpublic change").await;
733
734 for session in [None, Some(mallory.as_str())] {
735 let res = h.request("/search?q=widget&fragment=1", session).await;
736 assert_eq!(res.status(), StatusCode::OK);
737 let body = h.body(res).await;
738
739 // A fragment is a bare list, so it must not drag the page chrome with
740 // it — that would nest a whole document inside the overlay.
741 assert!(!body.contains("<html"), "the fragment must not be a full page: {body}");
742
743 assert!(!body.contains("widgetconfidential"), "private content in the palette: {body}");
744 assert!(!body.contains("widgetsecret"), "private repo name in the palette: {body}");
745 assert!(body.contains("widgetopen"), "the public repo should be findable");
746 }
747
748 let res = h.get_as("/search?q=widget&fragment=1", &alice_session).await;
749 let body = h.body(res).await;
750 assert!(body.contains("widgetconfidential"), "the owner must see their own: {body}");
751
752 h.drop_schema().await;
753}
754
755/// A profile page must not enumerate repositories the viewer cannot open.
756#[tokio::test]
757async fn a_profile_does_not_list_private_repositories_to_strangers() {
758 let Some(h) = harness("profile").await else { return };
759
760 let (alice, alice_session) = user(&h.db, "alice", false).await;
761 let (_, mallory) = user(&h.db, "mallory", false).await;
762 // Distinctive names: `hidden` would also match the markup of every
763 // `<input type="hidden">` on the page and the assertion would pass or fail
764 // for the wrong reason.
765 repo(&h.db, alice, "unlistable-repo", true).await;
766 repo(&h.db, alice, "listable-repo", false).await;
767
768 for session in [None, Some(mallory.as_str())] {
769 let res = h.request("/alice", session).await;
770 assert_eq!(res.status(), StatusCode::OK);
771 let body = h.body(res).await;
772 assert!(
773 !body.contains("unlistable-repo"),
774 "private repo listed on a profile: {body}"
775 );
776 assert!(body.contains("listable-repo"));
777 }
778
779 let body = h.body(h.get_as("/alice", &alice_session).await).await;
780 assert!(
781 body.contains("unlistable-repo"),
782 "the owner must see their own repositories"
783 );
784
785 h.drop_schema().await;
786}
787
788/// A collaborator grant is what opens a private repository, and nothing else.
789#[tokio::test]
790async fn a_collaborator_grant_is_what_opens_a_private_repo() {
791 let Some(h) = harness("collaborator").await else { return };
792
793 let (alice, _) = user(&h.db, "alice", false).await;
794 let (bob, bob_session) = user(&h.db, "bob", false).await;
795 let hidden = repo(&h.db, alice, "hidden", true).await;
796
797 assert_eq!(
798 h.get_as("/alice/hidden", &bob_session).await.status(),
799 StatusCode::NOT_FOUND
800 );
801
802 sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
803 .bind(hidden)
804 .bind(bob)
805 .execute(&h.db)
806 .await
807 .unwrap();
808
809 assert_eq!(
810 h.get_as("/alice/hidden", &bob_session).await.status(),
811 StatusCode::OK,
812 "a read collaborator must be able to open it"
813 );
814
815 // …but read is not settings.
816 assert_eq!(
817 h.get_as("/alice/hidden/settings", &bob_session).await.status(),
818 StatusCode::FORBIDDEN,
819 "read access must not reach settings"
820 );
821
822 h.drop_schema().await;
823}
824
825/// > Enforce authorization in middleware before the handler runs.
826///
827/// A `read` collaborator sees a 403 on settings, not a 404 — they can already
828/// see the repository, so nothing is disclosed — while a stranger sees a 404.
829/// The distinction is the whole design of `AppError`.
830#[tokio::test]
831async fn a_403_is_only_ever_shown_to_somebody_who_can_already_see_the_repo() {
832 let Some(h) = harness("403_vs_404").await else { return };
833
834 let (alice, _) = user(&h.db, "alice", false).await;
835 let (bob, bob_session) = user(&h.db, "bob", false).await;
836 let (_, mallory) = user(&h.db, "mallory", false).await;
837 let hidden = repo(&h.db, alice, "hidden", true).await;
838
839 sqlx::query("INSERT INTO repo_collaborators (repo_id, user_id, role) VALUES ($1, $2, 'read')")
840 .bind(hidden)
841 .bind(bob)
842 .execute(&h.db)
843 .await
844 .unwrap();
845
846 assert_eq!(
847 h.get_as("/alice/hidden/settings", &bob_session).await.status(),
848 StatusCode::FORBIDDEN
849 );
850 assert_eq!(
851 h.get_as("/alice/hidden/settings", &mallory).await.status(),
852 StatusCode::NOT_FOUND,
853 "a stranger must not learn the repository exists"
854 );
855
856 h.drop_schema().await;
857}
858
859/// > Strict CSP with no `unsafe-inline`, `nosniff`, and framing denied — on
860/// > every response, including errors.
861#[tokio::test]
862async fn security_headers_are_present_on_every_response() {
863 let Some(h) = harness("headers").await else { return };
864
865 for path in ["/", "/does-not-exist", "/login"] {
866 let res = h.get(path).await;
867 let headers = res.headers();
868
869 let csp = headers
870 .get("content-security-policy")
871 .and_then(|v| v.to_str().ok())
872 .unwrap_or_default()
873 .to_owned();
874
875 assert!(!csp.is_empty(), "{path} has no CSP");
876 assert!(!csp.contains("unsafe-inline"), "{path} CSP allows unsafe-inline: {csp}");
877 assert!(!csp.contains("unsafe-eval"), "{path} CSP allows unsafe-eval: {csp}");
878 assert!(csp.contains("frame-ancestors 'none'"), "{path}: {csp}");
879 assert!(csp.contains("object-src 'none'"), "{path}: {csp}");
880
881 assert_eq!(
882 headers.get("x-content-type-options").and_then(|v| v.to_str().ok()),
883 Some("nosniff"),
884 "{path}"
885 );
886 assert_eq!(
887 headers.get("x-frame-options").and_then(|v| v.to_str().ok()),
888 Some("DENY"),
889 "{path}"
890 );
891 }
892
893 h.drop_schema().await;
894}
895
896/// > `/metrics` — bind to loopback only.
897///
898/// Requests through this harness have no `ConnectInfo`, which is the same
899/// position a request arriving without a resolvable peer is in. The endpoint
900/// must refuse rather than default to serving.
901#[tokio::test]
902async fn metrics_are_not_served_without_a_loopback_peer() {
903 let Some(h) = harness("metrics").await else { return };
904
905 let res = h.get("/metrics").await;
906 assert_ne!(
907 res.status(),
908 StatusCode::OK,
909 "metrics must not be served to a request that cannot prove it is local"
910 );
911
912 h.drop_schema().await;
913}
914
915/// > CSRF: double-submit cookie, validated on every non-GET.
916#[tokio::test]
917async fn state_changing_requests_without_a_csrf_token_are_refused() {
918 let Some(h) = harness("csrf").await else { return };
919
920 let (alice, session) = user(&h.db, "alice", false).await;
921 repo(&h.db, alice, "r", false).await;
922
923 for (method, path) in [
924 ("POST", "/repos"),
925 ("POST", "/logout"),
926 ("POST", "/settings/keys"),
927 ("POST", "/alice/r/settings/general"),
928 ("POST", "/alice/r/issues"),
929 ] {
930 let req = Request::builder()
931 .uri(path)
932 .method(method)
933 .header("cookie", format!("{}={session}", df_auth::session::COOKIE_NAME))
934 .header("content-type", "application/x-www-form-urlencoded")
935 .body(Body::from("name=x"))
936 .unwrap();
937
938 let res = h.app.clone().oneshot(req).await.unwrap();
939 assert_eq!(
940 res.status(),
941 StatusCode::FORBIDDEN,
942 "{method} {path} was accepted without a CSRF token"
943 );
944 }
945
946 h.drop_schema().await;
947}
948
949/// > Blob content served with `Content-Disposition: attachment` and
950/// > `X-Content-Type-Options: nosniff` — never serve user-controlled HTML on
951/// > the app origin.
952///
953/// The store here cannot produce a blob, so this asserts the reachable part:
954/// that the raw route never answers with a content type a browser would render.
955/// The header construction itself is pinned by a unit test next to the handler.
956#[tokio::test]
957async fn the_raw_route_never_serves_a_renderable_content_type() {
958 let Some(h) = harness("raw").await else { return };
959
960 let (alice, _) = user(&h.db, "alice", false).await;
961 repo(&h.db, alice, "r", false).await;
962
963 let res = h.get("/alice/r/raw/main/evil.html").await;
964 let ct = res
965 .headers()
966 .get("content-type")
967 .and_then(|v| v.to_str().ok())
968 .unwrap_or_default()
969 .to_owned();
970 assert!(
971 !ct.contains("text/html") || res.status() != StatusCode::OK,
972 "raw served renderable HTML: {ct}"
973 );
974
975 h.drop_schema().await;
976}
977
978/// Path traversal must be refused at the parser, before any handler.
979#[tokio::test]
980async fn traversal_and_control_characters_are_refused() {
981 let Some(h) = harness("traversal").await else { return };
982
983 let (alice, _) = user(&h.db, "alice", false).await;
984 repo(&h.db, alice, "r", false).await;
985
986 for path in [
987 "/alice/r/blob/main/../../../../etc/passwd",
988 "/alice/r/blob/main/%2e%2e%2f%2e%2e%2fetc%2fpasswd",
989 "/alice/r/raw/main/%00etc/passwd",
990 "/alice/r/tree/main/../..",
991 ] {
992 let res = h.get(path).await;
993 assert!(
994 res.status().is_client_error(),
995 "{path} returned {} instead of a client error",
996 res.status()
997 );
998 }
999
1000 h.drop_schema().await;
1001}
1002
1003/// A signed-out visitor must not be able to act, only to look.
1004#[tokio::test]
1005async fn anonymous_visitors_cannot_reach_authenticated_pages() {
1006 let Some(h) = harness("anon").await else { return };
1007
1008 for path in ["/settings", "/new", "/orgs/new"] {
1009 let res = h.get(path).await;
1010 assert_eq!(
1011 res.status(),
1012 StatusCode::SEE_OTHER,
1013 "{path} should redirect an anonymous visitor to sign in"
1014 );
1015 assert_eq!(
1016 res.headers().get("location").and_then(|v| v.to_str().ok()),
1017 Some("/login"),
1018 "{path}"
1019 );
1020 }
1021
1022 h.drop_schema().await;
1023}
1024
1025/// The session cookie is a bearer token, and the row id is not a credential.
1026///
1027/// The cookie used to be `sessions.id`, a UUIDv7 — time-ordered, with a counter
1028/// that only reseeds once a millisecond, so ids minted together share their
1029/// leading bits and any other id from the same generator narrows the rest. The
1030/// id is still the primary key; what this pins is that presenting it no longer
1031/// signs anybody in.
1032#[tokio::test]
1033async fn a_session_row_id_is_not_a_credential() {
1034 let Some(h) = harness("sessionid").await else { return };
1035
1036 let (alice, token) = user(&h.db, "alice", false).await;
1037 repo(&h.db, alice, "hidden", true).await;
1038
1039 // The token works.
1040 assert_eq!(
1041 h.get_as("/alice/hidden", &token).await.status(),
1042 StatusCode::OK,
1043 "the session token must sign alice in"
1044 );
1045
1046 // The row id, which is what the old cookie carried, does not.
1047 let id: Uuid = sqlx::query_scalar("SELECT id FROM sessions WHERE user_id = $1")
1048 .bind(alice)
1049 .fetch_one(&h.db)
1050 .await
1051 .expect("the seeded session");
1052 assert_eq!(
1053 h.get_as("/alice/hidden", &id.to_string()).await.status(),
1054 StatusCode::NOT_FOUND,
1055 "a session id must not authenticate — it is an identifier, not a secret"
1056 );
1057
1058 // Neither does the stored hash, which is what a database leak would yield.
1059 let hash: String = sqlx::query_scalar("SELECT token_hash FROM sessions WHERE user_id = $1")
1060 .bind(alice)
1061 .fetch_one(&h.db)
1062 .await
1063 .expect("the seeded session");
1064 assert_ne!(hash, token, "the plaintext token must not be stored");
1065 assert_eq!(
1066 h.get_as("/alice/hidden", &hash).await.status(),
1067 StatusCode::NOT_FOUND,
1068 "the stored hash must not be replayable as the token"
1069 );
1070
1071 h.drop_schema().await;
1072}
1073
1074/// An archived repository is read-only. The settings page promises that in so
1075/// many words, so it has to be true on the wire — enforcing it only in the UI
1076/// would leave the promise false for every Git client.
1077#[tokio::test]
1078async fn an_archived_repository_refuses_pushes() {
1079 let Some(h) = harness("archived").await else { return };
1080
1081 let (alice, _) = user(&h.db, "alice", false).await;
1082 let repo_id = repo(&h.db, alice, "frozen", false).await;
1083 sqlx::query("UPDATE repos SET archived = true WHERE id = $1")
1084 .bind(repo_id)
1085 .execute(&h.db)
1086 .await
1087 .unwrap();
1088
1089 let token = df_auth::tokens::create(&h.db, alice, "push", &[], None)
1090 .await
1091 .expect("minting a token");
1092 let basic = base64::Engine::encode(
1093 &base64::engine::general_purpose::STANDARD,
1094 format!("alice:{}", token.plaintext),
1095 );
1096
1097 let req = Request::builder()
1098 .uri("/alice/frozen/git-receive-pack")
1099 .method("POST")
1100 .header("authorization", format!("Basic {basic}"))
1101 .header("content-type", "application/x-git-receive-pack-request")
1102 .body(Body::from("0000"))
1103 .unwrap();
1104
1105 let res = h.app.clone().oneshot(req).await.unwrap();
1106 assert_eq!(
1107 res.status(),
1108 StatusCode::BAD_REQUEST,
1109 "an archived repository accepted a push"
1110 );
1111
1112 // …and reads still work, or "archived" would just mean "deleted".
1113 assert_eq!(h.get("/alice/frozen").await.status(), StatusCode::OK);
1114
1115 h.drop_schema().await;
1116}
1117
1118/// A push to a repository the token's owner cannot write must be refused even
1119/// though the credentials are valid.
1120#[tokio::test]
1121async fn a_valid_token_does_not_grant_push_to_a_repo_you_cannot_write() {
1122 let Some(h) = harness("push_authz").await else { return };
1123
1124 let (alice, _) = user(&h.db, "alice", false).await;
1125 let (mallory, _) = user(&h.db, "mallory", false).await;
1126 repo(&h.db, alice, "theirs", true).await;
1127
1128 let token = df_auth::tokens::create(&h.db, mallory, "push", &[], None)
1129 .await
1130 .expect("minting a token");
1131 let basic = base64::Engine::encode(
1132 &base64::engine::general_purpose::STANDARD,
1133 format!("mallory:{}", token.plaintext),
1134 );
1135
1136 let req = Request::builder()
1137 .uri("/alice/theirs/git-receive-pack")
1138 .method("POST")
1139 .header("authorization", format!("Basic {basic}"))
1140 .body(Body::from("0000"))
1141 .unwrap();
1142
1143 let res = h.app.clone().oneshot(req).await.unwrap();
1144 assert_eq!(
1145 res.status(),
1146 StatusCode::NOT_FOUND,
1147 "a private repo must stay a 404 to an authenticated stranger, even on the push path"
1148 );
1149
1150 h.drop_schema().await;
1151}

1151 lines · Rust