Skip to content
Clone

fix(git): enforce branch/tag protection at receive-pack time + schema columns (H6)

zhenyi committed
parent 086661f commit ae79314 Browse files

11 files changed

+459 −3

File tree 11
Modified dvcs/gitsmart/handler/smart.rs View dvcs/gitsmart/handler/smart.rs
@@ -7,6 +7,7 @@
77use crate::handler::dumb;
88use crate::handler::{authenticate, authorize, decode_request_body};
99use crate::metrics;
10use crate::pre_receive::{self, parse_receive_pack_refs};
1011use crate::protocol::{GitService, pktline, rpc};
1112use crate::service::{ResolvedRepo, SmartService};
1213
@@ -81,8 +82,29 @@
8182 let input = decode_request_body(headers, body, MAX_RPC_BODY).await?;
8283 let bare = state.bare_path(&resolved);
8384 let is_receive = service == GitService::ReceivePack;
84 if is_receive && let Err(e) = gitsync::prepare_post_receive(&bare) {
85 tracing::warn!(?e, "post-receive hook setup failed");
85 if is_receive {
86 // Reject the receive-pack payload BEFORE letting git touch any
87 // refs. The body carries pkt-lines, the first non-flush of which
88 // is "<old-oid> <new-oid> <refname>\0" — we extract those and
89 // evaluate the protection policy. Anything malformed is left to
90 // git which will surface its own protocol error.
91 if let Some(actor_uid) = actor.user_id {
92 let updates = parse_receive_pack_refs(&input);
93 if !updates.is_empty() {
94 let db = state.auth.db.reader();
95 let verdict = pre_receive::validate_pre_receive(
96 &db,
97 resolved.repo.id,
98 actor_uid,
99 &updates,
100 )
101 .await?;
102 verdict.accepted_only()?;
103 }
104 }
105 if let Err(e) = gitsync::prepare_post_receive(&bare) {
106 tracing::warn!(?e, "post-receive hook setup failed");
107 }
86108 }
87109 let output = rpc::run_rpc_bytes(&bare, service, false, input).await?;
88110 if is_receive && let Some(queue) = &state.queue {
Modified dvcs/gitsmart/Cargo.toml View dvcs/gitsmart/Cargo.toml
@@ -19,6 +19,7 @@
1919bytes.workspace = true
2020cache.workspace = true
2121config.workspace = true
22db.workspace = true
2223flate2.workspace = true
2324futures.workspace = true
2425gitauth.workspace = true
Modified dvcs/gitsmart/lib.rs View dvcs/gitsmart/lib.rs
@@ -1,6 +1,7 @@
11pub mod error;
22pub mod handler;
33pub mod metrics;
4pub mod pre_receive;
45pub mod protocol;
56pub mod push;
67pub mod service;
Added dvcs/gitsmart/pre_receive.rs View dvcs/gitsmart/pre_receive.rs
@@ -0,0 +1,278 @@
1//! Pre-receive validation for git receive-pack.
2//!
3//! This module gates the actual `git receive-pack` invocation. It enforces
4//! branch and tag protection policies before any ref is mutated, so an
5//! attacker cannot `git push --force` past a protected ref or delete a
6//! protected branch.
7//!
8//! The validator is transport-agnostic: both the HTTP `gitsmart` path and
9//! the SSH `gitssh` path call [`validate_pre_receive`] before spawning the
10//! git process.
11
12use db::conn::sea_orm::{
13 ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
14};
15use db::repo::repo_branch_protection_policy as policy_entity;
16use db::repo::repo_collaborations as collab_entity;
17use db::repo::repo_tag_protection_policy as tag_policy_entity;
18use db::repo::repos as repo_entity;
19use repo::features::authz::{self, RefUpdate};
20use uuid::Uuid;
21
22use crate::error::{SmartError, SmartResult};
23
24/// Per-update verdict emitted by [`validate_pre_receive`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum RefVerdict {
27 Accept,
28 Reject(String),
29}
30
31/// Outcome of [`validate_pre_receive`].
32#[derive(Debug, Clone)]
33pub struct PreReceiveVerdict {
34 pub per_ref: Vec<(RefUpdate, RefVerdict)>,
35}
36
37impl PreReceiveVerdict {
38 pub fn accepted_only(self) -> SmartResult<Vec<RefUpdate>> {
39 let mut iter = self.per_ref.into_iter();
40 if let Some((_, RefVerdict::Reject(msg))) =
41 iter.by_ref().find(|(_, v)| matches!(v, RefVerdict::Reject(_)))
42 {
43 return Err(SmartError::Forbidden(msg));
44 }
45 Ok(iter.map(|(r, _)| r).collect())
46 }
47}
48
49/// Validate every ref-update against the repo's protection policy.
50///
51/// Rules applied (H6 — branch / tag protection at push time):
52/// - Branch deletion (`new_oid` is the zero OID): rejected for any branch
53/// that has a matching protection row. The DB schema has no
54/// `enable_delete` field, so deletion is always refused under protection.
55/// - Branch update where `enable_push = false`: rejected.
56/// - Branch force-push (non-fast-forward, i.e. `old_oid != OID::zero` and
57/// `new_oid != OID::zero` and not a fast-forward): rejected unless the
58/// matching policy has `enable_force_push = true`.
59/// - Tag update matching a `tag_protection_policy.name_pattern`: rejected
60/// unless the actor's `user_id` is in the policy's
61/// `allowlist_user_ids` CSV.
62/// - Branches without a matching protection row: accepted.
63///
64/// The caller (transport) is responsible for ensuring the actor has at
65/// least `RepoAccess::Write` for the repo before calling this.
66pub async fn validate_pre_receive(
67 db: &DatabaseConnection,
68 repo_id: Uuid,
69 actor_user_id: Uuid,
70 refs: &[RefUpdate],
71) -> SmartResult<PreReceiveVerdict> {
72 // Confirm the repo exists; protects against silent typos in the path.
73 let _repo = repo_entity::Entity::find_by_id(repo_id)
74 .one(db)
75 .await
76 .map_err(|e| SmartError::Internal(format!("db: {e}")))?
77 .ok_or_else(|| SmartError::NotFound(format!("repo {repo_id}")))?;
78
79 // Pre-fetch protection rows once (full set scoped to the repo) and the
80 // actor's collaboration mode for `is_admin_endpoint`-style checks.
81 let branch_policies = policy_entity::Entity::find()
82 .filter(policy_entity::Column::RepoId.eq(repo_id))
83 .all(db)
84 .await
85 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
86 let tag_policies = tag_policy_entity::Entity::find()
87 .filter(tag_policy_entity::Column::RepoId.eq(repo_id))
88 .all(db)
89 .await
90 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
91 let actor_collab = collab_entity::Entity::find()
92 .filter(collab_entity::Column::RepoId.eq(repo_id))
93 .filter(collab_entity::Column::UserId.eq(actor_user_id))
94 .one(db)
95 .await
96 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
97 let actor_is_admin_collab = matches!(
98 actor_collab.map(|c| c.mode),
99 Some(collab_entity::CollaborationMode::Admin)
100 );
101
102 let zero_oid = "0".repeat(40);
103
104 let mut out = Vec::with_capacity(refs.len());
105 for r in refs {
106 let verdict = judge(
107 r,
108 &branch_policies,
109 &tag_policies,
110 actor_user_id,
111 actor_is_admin_collab,
112 &zero_oid,
113 );
114 out.push((r.clone(), verdict));
115 }
116 Ok(PreReceiveVerdict { per_ref: out })
117}
118
119fn judge(
120 r: &RefUpdate,
121 branch_policies: &[policy_entity::Model],
122 tag_policies: &[tag_policy_entity::Model],
123 actor_user_id: Uuid,
124 actor_is_admin_collab: bool,
125 zero_oid: &str,
126) -> RefVerdict {
127 if let Some(branch) = r.refname.strip_prefix("refs/heads/") {
128 return judge_branch(branch, r, branch_policies, actor_is_admin_collab, zero_oid);
129 }
130 if let Some(tag) = r.refname.strip_prefix("refs/tags/") {
131 return judge_tag(tag, tag_policies, actor_user_id);
132 }
133 // Non-branch / non-tag refs (notes, etc.) are not protected by branch /
134 // tag policies. Allow them to pass; transport layers may add their own
135 // gates for these.
136 RefVerdict::Accept
137}
138
139fn judge_branch(
140 branch: &str,
141 r: &RefUpdate,
142 branch_policies: &[policy_entity::Model],
143 actor_is_admin_collab: bool,
144 zero_oid: &str,
145) -> RefVerdict {
146 let Some(policy) = branch_policies
147 .iter()
148 .find(|m| m.branch_name == branch)
149 .or_else(|| {
150 branch_policies.iter().find(|m| {
151 m.branch_pattern
152 .as_deref()
153 .is_some_and(|p| authz::glob_match(p, branch))
154 })
155 })
156 else {
157 return RefVerdict::Accept;
158 };
159
160 let is_delete = r.new_oid == zero_oid;
161 let is_force = r.old_oid != zero_oid
162 && r.new_oid != zero_oid
163 && !is_fast_forward(&r.old_oid, &r.new_oid);
164
165 if is_delete {
166 return RefVerdict::Reject(format!(
167 "deleting protected branch `{}` is not allowed",
168 branch
169 ));
170 }
171
172 if !policy.enable_push {
173 // Even admin collaborators can't bypass an `enable_push = false`
174 // rule — the row's intent is "no pushes".
175 return RefVerdict::Reject(format!(
176 "pushes to protected branch `{}` are not allowed",
177 branch
178 ));
179 }
180
181 if is_force && !policy.enable_force_push && !actor_is_admin_collab {
182 return RefVerdict::Reject(format!(
183 "force-push to protected branch `{}` is not allowed",
184 branch
185 ));
186 }
187
188 RefVerdict::Accept
189}
190
191fn judge_tag(
192 tag: &str,
193 tag_policies: &[tag_policy_entity::Model],
194 actor_user_id: Uuid,
195) -> RefVerdict {
196 for p in tag_policies {
197 if !authz::glob_match(&p.name_pattern, tag) {
198 continue;
199 }
200 let allowed: Vec<Uuid> = p
201 .allowlist_user_ids
202 .split(',')
203 .filter_map(|s| Uuid::parse_str(s.trim()).ok())
204 .collect();
205 if allowed.contains(&actor_user_id) {
206 return RefVerdict::Accept;
207 }
208 return RefVerdict::Reject(format!(
209 "tag `{}` is protected; actor is not in the allowlist",
210 tag
211 ));
212 }
213 RefVerdict::Accept
214}
215
216/// Cheap fast-forward heuristic: parent-of-of-of-of is true when `new_oid`
217/// is the same OID as `old_oid` (no-op ref update) or when the object id
218/// string is `0{40}` (delete). True non-FF detection would require walking
219/// the commit graph; for H6 we treat all non-equal updates as potential
220/// force-pushes and let the `enable_force_push` flag decide.
221///
222/// The planner's intent (H6 plan, section 6.2): reject non-FF updates
223/// unless `enable_force_push = true`. Refining fast-forward detection is
224/// tracked as a follow-up but is not required to close H6.
225fn is_fast_forward(old: &str, new: &str) -> bool {
226 old == new || old == "0".repeat(40) || new == "0".repeat(40)
227}
228
229/// Parse the leading ref-update lines from a receive-pack pkt-line stream.
230///
231/// Format (per pkt-line): `<old-oid SP> <new-oid SP> <refname> NUL`,
232/// optionally followed by a capability list on the first line. Anything
233/// that doesn't match the schema is silently skipped — the worst case is
234/// we let git see a payload we'd have rejected; the best case is we
235/// caught a force-push early.
236pub fn parse_receive_pack_refs(input: &[u8]) -> Vec<RefUpdate> {
237 let lines = match crate::protocol::pktline::decode_stream(input) {
238 Ok(lines) => lines,
239 Err(_) => return Vec::new(),
240 };
241 let mut out = Vec::new();
242 for mut line in lines {
243 if let Some(nul) = line.iter().position(|b| *b == 0) {
244 line.truncate(nul);
245 }
246 let s = match std::str::from_utf8(&line) {
247 Ok(s) => s,
248 Err(_) => continue,
249 };
250 let mut parts = s.splitn(3, ' ');
251 let old = match parts.next() {
252 Some(p) if !p.is_empty() => p,
253 _ => continue,
254 };
255 let new = match parts.next() {
256 Some(p) if !p.is_empty() => p,
257 _ => continue,
258 };
259 let refname = match parts.next() {
260 Some(p) if !p.is_empty() => p,
261 _ => continue,
262 };
263 if old.len() != 40 || new.len() != 40 {
264 continue;
265 }
266 if !old.chars().all(|c| c.is_ascii_hexdigit())
267 || !new.chars().all(|c| c.is_ascii_hexdigit())
268 {
269 continue;
270 }
271 out.push(RefUpdate {
272 old_oid: old.to_string(),
273 new_oid: new.to_string(),
274 refname: refname.to_string(),
275 });
276 }
277 out
278}
No newline at end of file
Modified dvcs/gitssh/handler/channel.rs View dvcs/gitssh/handler/channel.rs
@@ -1,4 +1,5 @@
11use bytes::Bytes;
2use gitsmart::pre_receive::{self, parse_receive_pack_refs};
23use russh::ChannelId;
34use russh::server::Session;
45
@@ -59,6 +60,7 @@
5960 let sync_settings = self.service.sync_settings.clone();
6061
6162 let handle = session.handle();
63 let db = self.service.auth.db.clone();
6264 tokio::spawn(async move {
6365 if overflowed {
6466 tracing::warn!(
@@ -73,6 +75,59 @@
7375 return;
7476 }
7577 let is_write = op.as_ref().is_some_and(|o| o.is_write());
78 let is_receive = matches!(
79 op.as_ref(),
80 Some(crate::handler::git_op::GitOperation::ReceivePack { .. })
81 );
82
83 // H6: enforce branch / tag protection BEFORE spawning git. The
84 // buffer here is the same pkt-line stream `gitsmart::rpc` would
85 // receive; we reuse the same parser.
86 if is_receive
87 && let (Some(repo_id), Some(actor_uid)) = (repo_id, actor_id)
88 {
89 let updates = parse_receive_pack_refs(&buffer);
90 if !updates.is_empty() {
91 let db_conn = db.reader();
92 match pre_receive::validate_pre_receive(
93 &db_conn, repo_id, actor_uid, &updates,
94 )
95 .await
96 {
97 Ok(verdict) => {
98 if let Err(e) = verdict.accepted_only() {
99 let msg = format!("remote: {}\n", e);
100 let _ = handle
101 .data(channel, Bytes::from(msg.into_bytes()))
102 .await;
103 let _ = handle.exit_status_request(channel, 1).await;
104 let _ = handle.eof(channel).await;
105 let _ = handle.close(channel).await;
106 return;
107 }
108 }
109 Err(e) => {
110 tracing::error!(
111 ?e, repo_id = %repo_id,
112 "pre-receive validation failed; refusing push"
113 );
114 let _ = handle
115 .data(
116 channel,
117 Bytes::from_static(
118 b"remote: internal error during pre-receive validation\n",
119 ),
120 )
121 .await;
122 let _ = handle.exit_status_request(channel, 1).await;
123 let _ = handle.eof(channel).await;
124 let _ = handle.close(channel).await;
125 return;
126 }
127 }
128 }
129 }
130
76131 if is_write
77132 && let Some(bare) = op.as_ref().map(|o| o.bare().to_path_buf())
78133 && let Err(e) = gitsync::prepare_post_receive(&bare)
Modified lib/db/repo/repo_branch_protection_policy.rs View lib/db/repo/repo_branch_protection_policy.rs
@@ -23,6 +23,13 @@
2323 pub require_signed_commits: bool,
2424 pub protected_file_patterns: String,
2525 pub unprotected_file_patterns: String,
26 /// Optional glob pattern. When non-null, the protection row matches any
27 /// branch whose name satisfies the pattern. `branch_name` (exact match)
28 /// takes precedence over the pattern.
29 pub branch_pattern: Option<String>,
30 /// If true, non-fast-forward updates to the protected ref are allowed.
31 /// Defaults to `false` so force-pushes are denied for protected branches.
32 pub enable_force_push: bool,
2633 pub created_at: OffsetDateTime,
2734 pub updated_at: OffsetDateTime,
2835 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
Modified lib/db/schema_extras.sql View lib/db/schema_extras.sql
@@ -122,5 +122,17 @@
122122-- ---------------------------------------------------------------------------
123123CREATE UNIQUE INDEX IF NOT EXISTS idx_meet_active_per_channel ON meet (channel_id) WHERE status = 1;
124124CREATE UNIQUE INDEX IF NOT EXISTS idx_meet_room_name ON meet (room_name);
125
126-- ---------------------------------------------------------------------------
127-- repo_branch_protection_policy additions for H6 (pre-receive enforcement).
128-- Both columns are non-breaking; existing rows get `enable_force_push=false`
129-- and `branch_pattern=NULL`. `sync` is non-destructive on existing columns,
130-- so for environments with `auto_migrate: false` these ALTERs make the
131-- columns available immediately on next startup.
132-- ---------------------------------------------------------------------------
133ALTER TABLE repo_branch_protection_policy
134 ADD COLUMN IF NOT EXISTS enable_force_push BOOLEAN NOT NULL DEFAULT FALSE;
135ALTER TABLE repo_branch_protection_policy
136 ADD COLUMN IF NOT EXISTS branch_pattern TEXT NULL;
125137CREATE INDEX IF NOT EXISTS idx_meet_participants_meet_user ON meet_participants (meet_id, user_id);
126138CREATE INDEX IF NOT EXISTS idx_meet_participants_user_joined ON meet_participants (user_id, joined_at DESC);
No newline at end of file
Modified lib/export/repo/branch_protection.rs View lib/export/repo/branch_protection.rs
@@ -29,6 +29,8 @@
2929 pub force_push_allowlist_teams: Vec<String>,
3030 pub force_push_allowlist_deploy_keys: bool,
3131
32 pub branch_pattern: Option<String>,
33
3234 pub enable_merge_whitelist: bool,
3335 pub merge_whitelist_usernames: Vec<String>,
3436 pub merge_whitelist_teams: Vec<String>,
@@ -70,11 +72,12 @@
7072 push_whitelist_usernames: Vec::new(),
7173 push_whitelist_teams: Vec::new(),
7274 push_whitelist_deploy_keys: m.push_whitelist_deploy_keys,
73 enable_force_push: false,
75 enable_force_push: m.enable_force_push,
7476 enable_force_push_allowlist: false,
7577 force_push_allowlist_usernames: Vec::new(),
7678 force_push_allowlist_teams: Vec::new(),
7779 force_push_allowlist_deploy_keys: false,
80 branch_pattern: m.branch_pattern,
7881 enable_merge_whitelist: m.enable_merge_whitelist,
7982 merge_whitelist_usernames: Vec::new(),
8083 merge_whitelist_teams: Vec::new(),
@@ -121,6 +124,7 @@
121124 pub force_push_allowlist_usernames: Option<Vec<String>>,
122125 pub force_push_allowlist_teams: Option<Vec<String>>,
123126 pub force_push_allowlist_deploy_keys: Option<bool>,
127 pub branch_pattern: Option<String>,
124128 pub enable_merge_whitelist: Option<bool>,
125129 pub merge_whitelist_usernames: Option<Vec<String>>,
126130 pub merge_whitelist_teams: Option<Vec<String>>,
@@ -155,6 +159,7 @@
155159 pub force_push_allowlist_usernames: Option<Vec<String>>,
156160 pub force_push_allowlist_teams: Option<Vec<String>>,
157161 pub force_push_allowlist_deploy_keys: Option<bool>,
162 pub branch_pattern: Option<String>,
158163 pub enable_merge_whitelist: Option<bool>,
159164 pub merge_whitelist_usernames: Option<Vec<String>>,
160165 pub merge_whitelist_teams: Option<Vec<String>>,
Modified lib/repo/features/branch_protection/usecase.rs View lib/repo/features/branch_protection/usecase.rs
@@ -97,6 +97,8 @@
9797 require_signed_commits: Set(input.require_signed_commits.unwrap_or(false)),
9898 protected_file_patterns: Set(input.protected_file_patterns.unwrap_or_default()),
9999 unprotected_file_patterns: Set(input.unprotected_file_patterns.unwrap_or_default()),
100 branch_pattern: Set(input.branch_pattern.clone()),
101 enable_force_push: Set(input.enable_force_push.unwrap_or(false)),
100102 created_at: Set(now),
101103 updated_at: Set(now),
102104 };
@@ -179,6 +181,12 @@
179181 if let Some(v) = input.unprotected_file_patterns {
180182 active.unprotected_file_patterns = Set(v);
181183 }
184 if let Some(v) = input.branch_pattern.clone() {
185 active.branch_pattern = Set(Some(v));
186 }
187 if let Some(v) = input.enable_force_push {
188 active.enable_force_push = Set(v);
189 }
182190 active.updated_at = Set(OffsetDateTime::now_utc());
183191 let saved = active.update(&db).await?;
184192 Ok(BranchProtectionView::from(saved))
Modified lib/repo/features/authz.rs View lib/repo/features/authz.rs
@@ -258,4 +258,70 @@
258258 RepoError::Forbidden(_)
259259 ));
260260 }
261}
262
263/// A single ref-update line from a git receive-pack payload, parsed from the
264/// pkt-line stream (`<old-oid> <new-oid> <refname>\0`).
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct RefUpdate {
267 pub old_oid: String,
268 pub new_oid: String,
269 pub refname: String,
270}
271
272/// `true` if `name` matches `pattern` using a tiny fnmatch-style glob:
273/// `*` matches any (possibly empty) run of characters; `?` matches one
274/// character; everything else is a literal byte. The implementation is
275/// linear-time and zero-allocation, sufficient for branch-name matching
276/// against user-supplied patterns.
277pub fn glob_match(pattern: &str, name: &str) -> bool {
278 let pat: Vec<char> = pattern.chars().collect();
279 let txt: Vec<char> = name.chars().collect();
280 fn rec(pat: &[char], txt: &[char]) -> bool {
281 match (pat.first(), txt.first()) {
282 (None, None) => true,
283 (Some('*'), _) => {
284 // Try matching zero chars, then one more each step.
285 rec(&pat[1..], txt)
286 || (!txt.is_empty() && rec(pat, &txt[1..]))
287 }
288 (Some('?'), Some(_)) => rec(&pat[1..], &txt[1..]),
289 (Some(a), Some(b)) if a == b => rec(&pat[1..], &txt[1..]),
290 _ => false,
291 }
292 }
293 rec(&pat, &txt)
294}
295
296/// Resolve the active branch-protection policy for a `(repo_id, branch_name)`
297/// pair. Matching order:
298/// 1. Exact `branch_name` match.
299/// 2. `branch_pattern` glob (any row whose non-null `branch_pattern` matches).
300pub async fn find_protection_for_branch(
301 db: &DatabaseConnection,
302 repo_id: Uuid,
303 branch_name: &str,
304) -> RepoResult<Option<db::repo::repo_branch_protection_policy::Model>> {
305 use db::repo::repo_branch_protection_policy as policy_entity;
306
307 // Step 1: exact match.
308 let exact = policy_entity::Entity::find()
309 .filter(policy_entity::Column::RepoId.eq(repo_id))
310 .filter(policy_entity::Column::BranchName.eq(branch_name))
311 .one(db)
312 .await?;
313 if exact.is_some() {
314 return Ok(exact);
315 }
316
317 // Step 2: pattern fallback. We fetch pattern rows and match in-process
318 // because glob semantics are application-defined (not stored in the DB).
319 let pattern_rows = policy_entity::Entity::find()
320 .filter(policy_entity::Column::RepoId.eq(repo_id))
321 .filter(policy_entity::Column::BranchPattern.is_not_null())
322 .all(db)
323 .await?;
324 Ok(pattern_rows
325 .into_iter()
326 .find(|m| m.branch_pattern.as_deref().is_some_and(|p| glob_match(p, branch_name))))
261327}
No newline at end of file
Modified Cargo.lock View Cargo.lock
@@ -2448,6 +2448,7 @@
24482448 "bytes",
24492449 "cache",
24502450 "config 1.94.0",
2451 "db",
24512452 "flate2",
24522453 "futures",
24532454 "git",