//! Page: CI secrets (`/{owner}/{repo}/settings/secrets`). Admins of the repo list the names of its CI //! secrets with the time each was last set, add or update one, and delete one. Anyone else gets a 404, //! so the page does not reveal that a repo has secrets. A value is write-only: it is sealed by the store //! and is never rendered, not in the list, not on a validation error, and not in the audit log (which //! records the name only). Names match `^[A-Z_][A-Z0-9_]{0,63}$`; values are at most 48 KiB. use forge_core::AuditAction; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::repo_admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::repo_mirrors::stamp_in_tz; use crate::pages::repo_settings::settings_nav_for; use crate::state::AppState; use crate::views::{alert, enc, shell}; path_param!(owner); path_param!(repo); /// Longest secret value accepted, in bytes. pub const MAX_VALUE_BYTES: usize = 48 * 1024; /// Longest secret name accepted, in characters. const MAX_NAME_CHARS: usize = 64; /// Notice words (the only text a `?notice=` query can show), their text, and whether they are errors. const NOTICES: [(&str, K, bool); 3] = [ ("saved", K::SecretSaved, false), ("deleted", K::SecretDeleted, false), ("missing", K::SecretMissing, true), ]; #[derive(Deserialize)] struct SetForm { #[serde(default)] csrf: String, #[serde(default)] name: String, #[serde(default)] value: String, } #[derive(Deserialize)] struct DeleteForm { #[serde(default)] csrf: String, #[serde(default)] name: String, } /// `^[A-Z_][A-Z0-9_]{0,63}$`, checked by hand so no regex engine is needed. pub fn valid_name(name: &str) -> bool { let bytes = name.as_bytes(); let Some((first, rest)) = bytes.split_first() else { return false; }; rest.len() < MAX_NAME_CHARS && (first.is_ascii_uppercase() || *first == b'_') && rest .iter() .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') } /// Checks a name and value before anything is stored. The message never includes the value. fn check_secret(lang: Lang, name: &str, value: &str) -> std::result::Result<(), String> { if !valid_name(name) { return Err(g::t(lang, K::NameRule).to_owned()); } if value.is_empty() { return Err(g::t(lang, K::EnterValue).to_owned()); } if value.len() > MAX_VALUE_BYTES { return Err(g::t(lang, K::ValueTooLarge).to_owned()); } Ok(()) } fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn secrets_href(title: &str) -> String { format!("/{title}/settings/secrets") } fn back_to_list(title: &str, notice: &str) -> SeeOther { see_other(format!("{}?notice={}", secrets_href(title), enc(notice))) } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx) -> Option<(K, bool)> { let query = uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (*key, *is_error)) } /// One row of the list: the name and when it was last set, already formatted. struct SecretRow { name: String, updated: String, } /// What the page shows, read once from the repo context and the store. struct SecretsData { page_title: String, title: String, archived: bool, head: RepoHead, rows: Vec, } impl SecretsData { async fn load(state: &AppState, ctx: &RepoCtx, lang: Lang) -> Result { let title = title_of(ctx); let tz = ctx.viewer_tz().to_owned(); let rows = state .store .list_ci_secret_names(&ctx.repo.id) .await? .into_iter() .map(|(name, updated)| SecretRow { name, updated: stamp_in_tz(&updated, &tz), }) .collect(); Ok(Self { page_title: g::secrets_page_title(lang, &title), title, archived: ctx.repo.archived, head: ctx.head(state, "settings").await?, rows, }) } } /// Header, sub-navigation and the one-line notice. #[component] async fn frame_top( lang: Lang, head: RepoHead, title: String, notice: Option<(K, bool)>, archived: bool, ) -> Result { let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error)); Ok(view! { repo_head(head: head) settings_nav_for(lang: lang, title: title, current: "secrets") if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} } if archived {
(g::t(lang, K::ArchivedSecrets))
} }) } /// The list, the add form and the delete buttons. `draft_name` and `error` come from a refused add; /// the value is never passed back, so the textarea is always empty. #[component] async fn secrets_body( lang: Lang, head: RepoHead, title: String, notice: Option<(K, bool)>, archived: bool, csrf: String, rows: Vec, draft_name: String, error: Option, ) -> Result { let set_action = secrets_href(&title); let delete_action = format!("{set_action}/delete"); Ok(view! { frame_top(lang: lang, head: head, title: title.clone(), notice: notice, archived: archived)

(g::t(lang, K::Secrets))

(g::t(lang, K::SecretsHelp))

if rows.is_empty() {

(g::t(lang, K::NoSecrets))

} else {
for row in rows.iter() { }
(g::t(lang, K::Name)) (g::t(lang, K::LastUpdated))
(row.name.as_str()) (row.updated.as_str()) if !archived {
}
}
if !archived {

(g::t(lang, K::AddOrUpdate))

if let Some(message) = error { }

(g::t(lang, K::SecretLimits))

} }) } /// Lists the repo's CI secret names. Admins only: anyone else gets 404. #[page("/{owner}/{repo}/settings/secrets")] async fn secrets_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(not_found().into()); } let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let data = SecretsData::load(state, &ctx, lang).await?; let csrf = session.csrf_token.clone(); let notice = notice_from_query(cx); Ok(view! { shell(title: data.page_title.as_str(), viewer: ctx.viewer.as_ref(), secrets_body(lang: lang, head: data.head, title: data.title, notice: notice, archived: data.archived, csrf: csrf, rows: data.rows, draft_name: String::new(), error: None)) }) } /// Adds or updates a secret. A refused name or value re-renders the page with the message; the value /// is dropped, not sent back. #[page(POST "/{owner}/{repo}/settings/secrets")] async fn secret_set(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let secret_name = form.name.trim().to_owned(); if let Err(message) = check_secret(lang, &secret_name, &form.value) { let data = SecretsData::load(state, &ctx, lang).await?; let csrf = session.csrf_token.clone(); return Ok(view! { shell(title: data.page_title.as_str(), viewer: ctx.viewer.as_ref(), secrets_body(lang: lang, head: data.head, title: data.title, notice: None, archived: data.archived, csrf: csrf, rows: data.rows, draft_name: secret_name.clone(), error: Some(message))) }); } state .store .set_ci_secret(&ctx.repo.id, &secret_name, form.value.as_bytes()) .await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::CiSecretSet, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({ "name": secret_name }), ) .await; tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, "ci secret set"); Err(back_to_list(&title_of(&ctx), "saved").into()) } /// Deletes a secret by name. A name that is not stored is refused with a notice. #[route(POST "/{owner}/{repo}/settings/secrets/delete")] async fn secret_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let title = title_of(&ctx); let secret_name = form.name.trim().to_owned(); if !valid_name(&secret_name) || !state .store .delete_ci_secret(&ctx.repo.id, &secret_name) .await? { return Ok(back_to_list(&title, "missing")); } audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::CiSecretDeleted, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "name": secret_name }), ) .await; tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, "ci secret deleted"); Ok(back_to_list(&title, "deleted")) } #[cfg(test)] mod tests { use super::*; use forge_core::{Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "secrets-test-csrf"; const SECRET_VALUE: &str = "s3cr3t-value-DO-NOT-LEAK"; /// Alice owns `proj`, bob is a Write collaborator, carol is outside the repo. async fn fixture(dir: &std::path::Path) -> (Store, Router, forge_core::Repo) { let state = test_state(dir).await; let store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); let carol = store .create_user("carol", None, "unused", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main").await.expect("init"); store .set_collaborator(&repo.id, &bob.id, forge_core::Role::Write) .await .expect("collaborator"); create_session(&store, &alice.id, "alice-token", CSRF).await; create_session(&store, &bob.id, "bob-token", CSRF).await; create_session(&store, &carol.id, "carol-token", CSRF).await; (store, app_router(state), repo) } fn cookie_for(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// Sends a request. A POST carries the CSRF token unless `csrf` overrides it. Returns the status, /// the Location header and the body. async fn send( app: &Router, method: &str, uri: &str, token: Option<&str>, fields: &[(&str, &str)], csrf: Option<&str>, ) -> (u16, Option, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", cookie_for(token)); } let body = if method == "POST" { let mut form = form_urlencoded::Serializer::new(String::new()); form.append_pair("csrf", csrf.unwrap_or(CSRF)); for (key, value) in fields { form.append_pair(key, value); } builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form.finish()) } else { Body::empty() }; let res = app.handle(builder.body(body).expect("request")).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); ( status, location, String::from_utf8_lossy(&bytes).into_owned(), ) } const PAGE: &str = "/alice/proj/settings/secrets"; async fn names(store: &Store, repo: &forge_core::Repo) -> Vec { store .list_ci_secret_names(&repo.id) .await .expect("names") .into_iter() .map(|(name, _)| name) .collect() } #[test] fn names_follow_the_pattern() { for ok in ["A", "_", "DEPLOY_TOKEN", "X1", "_9"] { assert!(valid_name(ok), "{ok} should be valid"); } let long = format!("A{}", "1".repeat(63)); assert!(valid_name(&long), "64 characters is the limit"); let too_long = format!("A{}", "1".repeat(64)); for bad in [ "", "1A", "lower", "A-B", "A B", "A.B", "AÉ", too_long.as_str(), ] { assert!(!valid_name(bad), "{bad:?} should be rejected"); } } #[tokio::test] async fn non_admin_gets_404_and_anonymous_is_sent_to_login() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let (status, _, _) = send(&app, "GET", PAGE, Some("bob-token"), &[], None).await; assert_eq!(status, 404, "a write collaborator is not an admin"); let (status, _, _) = send(&app, "GET", PAGE, Some("carol-token"), &[], None).await; assert_eq!(status, 404, "an outsider gets 404"); let (status, _, _) = send( &app, "POST", PAGE, Some("bob-token"), &[("name", "DEPLOY"), ("value", "x")], None, ) .await; assert_eq!(status, 404); let (status, _, _) = send( &app, "POST", "/alice/proj/settings/secrets/delete", Some("carol-token"), &[("name", "DEPLOY")], None, ) .await; assert_eq!(status, 404); assert!(names(&store, &repo).await.is_empty(), "nothing was stored"); let (status, _, _) = send(&app, "GET", PAGE, None, &[], None).await; assert_ne!(status, 200, "anonymous users do not see the page"); } #[tokio::test] async fn post_without_a_valid_csrf_token_is_forbidden() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let (status, _, _) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "DEPLOY"), ("value", SECRET_VALUE)], Some("wrong-token"), ) .await; assert_eq!(status, 403); let (status, _, _) = send( &app, "POST", "/alice/proj/settings/secrets/delete", Some("alice-token"), &[("name", "DEPLOY")], Some(""), ) .await; assert_eq!(status, 403); assert!(names(&store, &repo).await.is_empty()); } #[tokio::test] async fn value_never_appears_in_any_response() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let (status, location, body) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "DEPLOY_TOKEN"), ("value", SECRET_VALUE)], None, ) .await; assert_eq!(status, 303); assert!(location.is_some_and(|l| l.contains("notice=saved"))); assert!(!body.contains(SECRET_VALUE)); assert_eq!(names(&store, &repo).await, vec!["DEPLOY_TOKEN".to_owned()]); let (_, _, page) = send(&app, "GET", PAGE, Some("alice-token"), &[], None).await; assert!(page.contains("DEPLOY_TOKEN"), "the name is listed"); assert!(!page.contains(SECRET_VALUE), "the value is never listed"); // A refused name with a value: the page comes back with the error and no value. let (status, _, refused) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "bad-name"), ("value", SECRET_VALUE)], None, ) .await; assert_eq!(status, 200); assert!(refused.contains("Names are 1 to 64 characters")); assert!( !refused.contains(SECRET_VALUE), "no echo after a validation error" ); assert_eq!(names(&store, &repo).await.len(), 1); } #[tokio::test] async fn bad_name_and_bad_value_are_rejected() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let too_long = format!("A{}", "1".repeat(64)); for name in ["", "lower", "1NUM", "A-B", too_long.as_str()] { let (status, _, body) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", name), ("value", "v")], None, ) .await; assert_eq!(status, 200, "{name:?} re-renders the form"); assert!(body.contains("Names are 1 to 64 characters"), "{name:?}"); } let (_, _, body) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "EMPTY"), ("value", "")], None, ) .await; assert!(body.contains("Enter a value.")); let oversize = "x".repeat(MAX_VALUE_BYTES + 1); let (_, _, body) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "BIG"), ("value", oversize.as_str())], None, ) .await; assert!(body.contains("larger than 48 KiB")); assert!(names(&store, &repo).await.is_empty(), "nothing stored"); let exact = "y".repeat(MAX_VALUE_BYTES); let (status, _, _) = send( &app, "POST", PAGE, Some("alice-token"), &[("name", "EXACT"), ("value", exact.as_str())], None, ) .await; assert_eq!(status, 303, "48 KiB exactly is accepted"); assert_eq!(names(&store, &repo).await, vec!["EXACT".to_owned()]); } #[tokio::test] async fn set_twice_updates_and_delete_removes() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; for value in ["first", "second"] { send( &app, "POST", PAGE, Some("alice-token"), &[("name", "API_KEY"), ("value", value)], None, ) .await; } assert_eq!(names(&store, &repo).await, vec!["API_KEY".to_owned()]); let delete = "/alice/proj/settings/secrets/delete"; let (status, location, _) = send( &app, "POST", delete, Some("alice-token"), &[("name", "API_KEY")], None, ) .await; assert_eq!(status, 303); assert!(location.is_some_and(|l| l.contains("notice=deleted"))); assert!(names(&store, &repo).await.is_empty()); let (_, location, _) = send( &app, "POST", delete, Some("alice-token"), &[("name", "API_KEY")], None, ) .await; assert!(location.is_some_and(|l| l.contains("notice=missing"))); } #[tokio::test] async fn turkish_viewer_sees_turkish_heading() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, _repo) = fixture(dir.path()).await; let alice = store .user_by_username("alice") .await .expect("lookup") .expect("alice"); store .set_ui_prefs(&alice.id, "auto", "tr") .await .expect("set language"); let (status, _, body) = send(&app, "GET", PAGE, Some("alice-token"), &[], None).await; assert_eq!(status, 200); assert!( body.contains("Gizli anahtar ekle veya güncelle"), "Turkish heading" ); } }