From 5a932318fbd765db5167b18bbde98d96ca1ccfc2 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:00:27 +0200 Subject: [PATCH 01/10] feat(core): ip_allowlist module --- backend/core/src/ip_allowlist.rs | 51 +++++++++++++++++++ backend/core/src/lib.rs | 1 + backend/core/tests/ip_allowlist.rs | 81 ++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 backend/core/src/ip_allowlist.rs create mode 100644 backend/core/tests/ip_allowlist.rs diff --git a/backend/core/src/ip_allowlist.rs b/backend/core/src/ip_allowlist.rs new file mode 100644 index 00000000..20d6776d --- /dev/null +++ b/backend/core/src/ip_allowlist.rs @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Source-IP allowlist matching for API keys and inbound integrations. +//! Empty list = allow all (backwards compatible with existing rows). + +use ipnet::IpNet; +use std::net::IpAddr; + +pub fn is_allowed(ip: IpAddr, allowlist: &[String]) -> bool { + if allowlist.is_empty() { + return true; + } + let ip = normalize(ip); + allowlist + .iter() + .filter_map(|s| s.parse::().ok()) + .any(|net| net.contains(&ip)) +} + +pub fn normalize_entry(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty entry".into()); + } + if let Ok(net) = trimmed.parse::() { + return Ok(net.to_string()); + } + if let Ok(ip) = trimmed.parse::() { + let prefix = match ip { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let net = IpNet::new(ip, prefix).map_err(|e| e.to_string())?; + return Ok(net.to_string()); + } + Err(format!("not a valid IP or CIDR: {trimmed}")) +} + +fn normalize(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => IpAddr::V6(v6), + }, + v => v, + } +} diff --git a/backend/core/src/lib.rs b/backend/core/src/lib.rs index 72937451..12aa0236 100644 --- a/backend/core/src/lib.rs +++ b/backend/core/src/lib.rs @@ -10,6 +10,7 @@ pub mod db; pub mod executer; pub mod http; pub mod hydra; +pub mod ip_allowlist; pub mod nix; pub mod nix_hash; pub mod permissions; diff --git a/backend/core/tests/ip_allowlist.rs b/backend/core/tests/ip_allowlist.rs new file mode 100644 index 00000000..7f3d5c15 --- /dev/null +++ b/backend/core/tests/ip_allowlist.rs @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +use core::ip_allowlist::{is_allowed, normalize_entry}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) +} + +#[test] +fn empty_list_allows_everything() { + assert!(is_allowed(v4(1, 2, 3, 4), &[])); +} + +#[test] +fn slash_32_exact_match() { + let list = vec!["203.0.113.5/32".to_string()]; + assert!(is_allowed(v4(203, 0, 113, 5), &list)); + assert!(!is_allowed(v4(203, 0, 113, 6), &list)); +} + +#[test] +fn slash_24_contains_address() { + let list = vec!["10.0.0.0/24".to_string()]; + assert!(is_allowed(v4(10, 0, 0, 1), &list)); + assert!(is_allowed(v4(10, 0, 0, 254), &list)); + assert!(!is_allowed(v4(10, 0, 1, 0), &list)); +} + +#[test] +fn ipv4_mapped_ipv6_matches_ipv4_cidr() { + let list = vec!["10.0.0.0/24".to_string()]; + let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001)); + assert!(is_allowed(mapped, &list)); +} + +#[test] +fn malformed_entry_is_skipped_but_others_still_count() { + let list = vec!["not-an-ip".to_string(), "10.0.0.0/24".to_string()]; + assert!(is_allowed(v4(10, 0, 0, 1), &list)); +} + +#[test] +fn no_match_when_all_entries_fail() { + let list = vec!["172.16.0.0/12".to_string()]; + assert!(!is_allowed(v4(192, 168, 1, 1), &list)); +} + +#[test] +fn normalize_bare_ipv4_to_slash_32() { + assert_eq!(normalize_entry("203.0.113.5").unwrap(), "203.0.113.5/32"); +} + +#[test] +fn normalize_bare_ipv6_to_slash_128() { + assert_eq!(normalize_entry("2001:db8::1").unwrap(), "2001:db8::1/128"); +} + +#[test] +fn normalize_keeps_cidr_unchanged() { + assert_eq!(normalize_entry("10.0.0.0/8").unwrap(), "10.0.0.0/8"); +} + +#[test] +fn normalize_trims_whitespace() { + assert_eq!(normalize_entry(" 10.0.0.0/8 ").unwrap(), "10.0.0.0/8"); +} + +#[test] +fn normalize_rejects_garbage() { + assert!(normalize_entry("hello world").is_err()); +} + +#[test] +fn normalize_rejects_empty() { + assert!(normalize_entry(" ").is_err()); +} From 236ff001f020bfac5f8fdda9bd1f4f128e89e809 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:01:12 +0200 Subject: [PATCH 02/10] feat(migration): add allowed_ips to api and integration --- backend/migration/src/lib.rs | 4 ++ ...m20260527_000001_add_allowed_ips_to_api.rs | 47 +++++++++++++++++++ ...7_000002_add_allowed_ips_to_integration.rs | 47 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 backend/migration/src/m20260527_000001_add_allowed_ips_to_api.rs create mode 100644 backend/migration/src/m20260527_000002_add_allowed_ips_to_integration.rs diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 19c1a05c..232fc3ca 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -118,6 +118,8 @@ mod m20260525_000002_add_cache_to_api; mod m20260525_000003_create_admin_task; mod m20260525_000004_evaluation_check_run_ids; mod m20260527_000000_evaluation_source_comment; +mod m20260527_000001_add_allowed_ips_to_api; +mod m20260527_000002_add_allowed_ips_to_integration; pub struct Migrator; @@ -237,6 +239,8 @@ impl MigratorTrait for Migrator { Box::new(m20260525_000003_create_admin_task::Migration), Box::new(m20260525_000004_evaluation_check_run_ids::Migration), Box::new(m20260527_000000_evaluation_source_comment::Migration), + Box::new(m20260527_000001_add_allowed_ips_to_api::Migration), + Box::new(m20260527_000002_add_allowed_ips_to_integration::Migration), ] } } diff --git a/backend/migration/src/m20260527_000001_add_allowed_ips_to_api.rs b/backend/migration/src/m20260527_000001_add_allowed_ips_to_api.rs new file mode 100644 index 00000000..5a8cd22b --- /dev/null +++ b/backend/migration/src/m20260527_000001_add_allowed_ips_to_api.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Nullable `allowed_ips TEXT[]` on `api`; NULL/empty = allow any source. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Api::Table) + .add_column( + ColumnDef::new(Api::AllowedIps) + .array(ColumnType::Text) + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Api::Table) + .drop_column(Api::AllowedIps) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Api { + Table, + AllowedIps, +} diff --git a/backend/migration/src/m20260527_000002_add_allowed_ips_to_integration.rs b/backend/migration/src/m20260527_000002_add_allowed_ips_to_integration.rs new file mode 100644 index 00000000..026d2ab8 --- /dev/null +++ b/backend/migration/src/m20260527_000002_add_allowed_ips_to_integration.rs @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2026 Wavelens GmbH + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Nullable `allowed_ips TEXT[]` on `integration`; NULL/empty = allow any source. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Integration::Table) + .add_column( + ColumnDef::new(Integration::AllowedIps) + .array(ColumnType::Text) + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Integration::Table) + .drop_column(Integration::AllowedIps) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Integration { + Table, + AllowedIps, +} From 37e775bdfe4829cab7d6a2aba691bba17c3627e5 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:03:51 +0200 Subject: [PATCH 03/10] feat(entity): allowed_ips field on api and integration --- backend/core/src/ci/integration_lookup.rs | 1 + backend/core/src/state/provisioning.rs | 2 ++ backend/entity/src/api.rs | 4 ++++ backend/entity/src/integration.rs | 4 ++++ backend/web/src/endpoints/orgs/integrations.rs | 1 + backend/web/src/endpoints/user.rs | 1 + 6 files changed, 13 insertions(+) diff --git a/backend/core/src/ci/integration_lookup.rs b/backend/core/src/ci/integration_lookup.rs index c10dd0d1..26016b23 100644 --- a/backend/core/src/ci/integration_lookup.rs +++ b/backend/core/src/ci/integration_lookup.rs @@ -96,6 +96,7 @@ pub async fn ensure_github_app_integrations( secret: Set(None), endpoint_url: Set(None), access_token: Set(None), + allowed_ips: Set(None), created_by: Set(creator), created_at: Set(chrono::Utc::now().naive_utc()), } diff --git a/backend/core/src/state/provisioning.rs b/backend/core/src/state/provisioning.rs index 53df50d9..dc9be604 100644 --- a/backend/core/src/state/provisioning.rs +++ b/backend/core/src/state/provisioning.rs @@ -1398,6 +1398,7 @@ impl<'a> StateApplicator<'a> { permission: Set(mask), organization: Set(pinned_org), cache: Set(None), + allowed_ips: Set(None), }; api_key_model.insert(self.db).await?; tracing::info!(name = %state_api_key.name, "Created managed API key"); @@ -1598,6 +1599,7 @@ impl<'a> StateApplicator<'a> { secret: Set(encrypted_secret), endpoint_url: Set(endpoint), access_token: Set(encrypted_token), + allowed_ips: Set(None), created_by: Set(created_by_id), created_at: Set(now()), }; diff --git a/backend/entity/src/api.rs b/backend/entity/src/api.rs index acacfa3a..ce4cb9b6 100644 --- a/backend/entity/src/api.rs +++ b/backend/entity/src/api.rs @@ -31,6 +31,9 @@ pub struct Model { /// other org. pub organization: Option, pub cache: Option, + /// Source CIDRs allowed to present this key. `None`/empty = any source. + #[sea_orm(column_type = "Array(std::sync::Arc::new(ColumnType::Text))", nullable)] + pub allowed_ips: Option>, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -74,6 +77,7 @@ impl std::fmt::Debug for Model { .field("permission", &self.permission) .field("organization", &self.organization) .field("cache", &self.cache) + .field("allowed_ips", &self.allowed_ips) .finish() } } diff --git a/backend/entity/src/integration.rs b/backend/entity/src/integration.rs index f588a1ca..5fd9bafe 100644 --- a/backend/entity/src/integration.rs +++ b/backend/entity/src/integration.rs @@ -29,6 +29,9 @@ pub struct Model { pub endpoint_url: Option, #[sea_orm(column_type = "Text", nullable)] pub access_token: Option, + /// Source CIDRs allowed for inbound webhooks. `None`/empty = any source. + #[sea_orm(column_type = "Array(std::sync::Arc::new(ColumnType::Text))", nullable)] + pub allowed_ips: Option>, pub created_by: UserId, pub created_at: NaiveDateTime, } @@ -64,6 +67,7 @@ impl std::fmt::Debug for Model { "access_token", &self.access_token.as_ref().map(|_| "[redacted]"), ) + .field("allowed_ips", &self.allowed_ips) .field("created_by", &self.created_by) .field("created_at", &self.created_at) .finish() diff --git a/backend/web/src/endpoints/orgs/integrations.rs b/backend/web/src/endpoints/orgs/integrations.rs index 21c365e1..664efd9a 100644 --- a/backend/web/src/endpoints/orgs/integrations.rs +++ b/backend/web/src/endpoints/orgs/integrations.rs @@ -317,6 +317,7 @@ pub async fn put_integration( secret: Set(encrypted_secret), endpoint_url: Set(endpoint_url), access_token: Set(encrypted_token), + allowed_ips: Set(None), created_by: Set(user.id), created_at: Set(gradient_core::types::now()), }; diff --git a/backend/web/src/endpoints/user.rs b/backend/web/src/endpoints/user.rs index b55d334c..860e944a 100644 --- a/backend/web/src/endpoints/user.rs +++ b/backend/web/src/endpoints/user.rs @@ -432,6 +432,7 @@ pub async fn post_keys( permission: Set(mask), organization: Set(org_pin), cache: Set(cache_pin), + allowed_ips: Set(None), }; let inserted = api_key.insert(&state.web_db).await?; From 05b35b29ea2dad21a79590641fe9f2be55c30cd6 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:16:20 +0200 Subject: [PATCH 04/10] feat(web): enforce api key allowed_ips --- backend/web/src/authorization/api_key.rs | 8 ++- backend/web/src/authorization/jwt.rs | 1 + backend/web/src/authorization/middleware.rs | 54 ++++++++++++--- backend/web/src/client_ip.rs | 7 ++ backend/web/src/endpoints/badges.rs | 18 ++++- backend/web/src/endpoints/caches/build_log.rs | 7 +- backend/web/src/endpoints/caches/helpers.rs | 69 +++++++++++++++---- backend/web/src/endpoints/caches/nar.rs | 11 ++- backend/web/src/endpoints/caches/narinfo.rs | 13 ++-- backend/web/src/endpoints/caches/narlist.rs | 7 +- backend/web/src/endpoints/caches/serve.rs | 7 +- .../web/src/endpoints/projects/evaluations.rs | 13 +++- backend/web/src/error.rs | 4 ++ 13 files changed, 175 insertions(+), 44 deletions(-) diff --git a/backend/web/src/authorization/api_key.rs b/backend/web/src/authorization/api_key.rs index 31474e4e..92a0b930 100644 --- a/backend/web/src/authorization/api_key.rs +++ b/backend/web/src/authorization/api_key.rs @@ -16,7 +16,7 @@ use crate::permissions::PermissionMask; use gradient_core::types::ids::CacheId; use gradient_core::types::{ApiId, OrganizationId, UserId}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiKeyContext { pub api_id: ApiId, pub mask: PermissionMask, @@ -26,6 +26,8 @@ pub struct ApiKeyContext { pub cache_pin: Option, /// Cache-permission mask. `None` means unrestricted (i64::MAX). pub cache_permission_mask: Option, + /// Source-IP allowlist (CIDR strings). Empty = any source allowed. + pub allowed_ips: Vec, } /// Extension type inserted on every authenticated request. @@ -89,8 +91,9 @@ mod tests { ))), cache_pin: None, cache_permission_mask: None, + allowed_ips: Vec::new(), }; - let wrapped = MaybeApiKey::from_key(ctx); + let wrapped = MaybeApiKey::from_key(ctx.clone()); assert_eq!(wrapped.as_ref(), Some(&ctx)); } @@ -123,6 +126,7 @@ mod tests { organization: None, cache_pin: None, cache_permission_mask: None, + allowed_ips: Vec::new(), }, }; let ctx = outcome.api_key_context().expect("present"); diff --git a/backend/web/src/authorization/jwt.rs b/backend/web/src/authorization/jwt.rs index 534f6152..0376ff27 100644 --- a/backend/web/src/authorization/jwt.rs +++ b/backend/web/src/authorization/jwt.rs @@ -187,6 +187,7 @@ async fn decode_api_key( } else { None }, + allowed_ips: api_key.allowed_ips.clone().unwrap_or_default(), }; let user_id = api_key.owned_by; diff --git a/backend/web/src/authorization/middleware.rs b/backend/web/src/authorization/middleware.rs index 6095dd43..439a472c 100644 --- a/backend/web/src/authorization/middleware.rs +++ b/backend/web/src/authorization/middleware.rs @@ -18,7 +18,9 @@ use std::sync::Arc; use super::api_key::MaybeApiKey; use super::jwt::{decode_jwt, extract_bearer_or_cookie, token_from_cookie}; use crate::audit::{RequestInfo, events, record as audit_record}; -use crate::error::{WebError, WebResult}; +use crate::client_ip::{ClientIp, resolve_client_ip}; +use crate::error::{ErrorCode, WebError, WebResult}; +use gradient_core::ip_allowlist::is_allowed as ip_allowed; /// Extension type for optional authentication. /// Inserted by `authorize_optional` into every request regardless of whether @@ -123,8 +125,26 @@ pub async fn authorize( }; let user_id = decoded.user_id(); + let client_ip = resolve_client_ip(req.headers(), peer, &state.config.network.trusted_proxies); let api_key_extension = match decoded.api_key_context() { - Some(ctx) => MaybeApiKey::from_key(*ctx), + Some(ctx) => { + if !ip_allowed(client_ip, &ctx.allowed_ips) { + audit_deny( + &state, + Some(user_id), + info, + method, + path, + "API key source IP not allowed", + ) + .await; + return Err(WebError::forbidden_with( + ErrorCode::FORBIDDEN_SOURCE_IP, + "API key not allowed from this source IP", + )); + } + MaybeApiKey::from_key(ctx.clone()) + } None => MaybeApiKey::none(), }; @@ -138,6 +158,7 @@ pub async fn authorize( req.extensions_mut().insert(current_user); req.extensions_mut().insert(api_key_extension); + req.extensions_mut().insert(ClientIp(client_ip)); Ok(next.run(req).await) } @@ -153,21 +174,36 @@ pub async fn authorize_optional( let mut maybe_user: Option = None; let mut maybe_api_key = MaybeApiKey::none(); + let peer = req + .extensions() + .get::>() + .map(|c| c.0.ip()) + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + let client_ip = + resolve_client_ip(req.headers(), peer, &state.config.network.trusted_proxies); + if let Some(token_str) = extract_bearer_or_cookie(req.headers()) && let Ok(decoded) = decode_jwt(State(Arc::clone(&state)), token_str).await { - if let Some(ctx) = decoded.api_key_context() { - maybe_api_key = MaybeApiKey::from_key(*ctx); + let ip_ok = match decoded.api_key_context() { + Some(ctx) => ip_allowed(client_ip, &ctx.allowed_ips), + None => true, + }; + if ip_ok { + if let Some(ctx) = decoded.api_key_context() { + maybe_api_key = MaybeApiKey::from_key(ctx.clone()); + } + maybe_user = EUser::find_by_id(decoded.user_id()) + .one(&state.web_db) + .await + .ok() + .flatten(); } - maybe_user = EUser::find_by_id(decoded.user_id()) - .one(&state.web_db) - .await - .ok() - .flatten(); } req.extensions_mut().insert(MaybeUser(maybe_user)); req.extensions_mut().insert(maybe_api_key); + req.extensions_mut().insert(ClientIp(client_ip)); next.run(req).await } diff --git a/backend/web/src/client_ip.rs b/backend/web/src/client_ip.rs index 1914e22c..fb277dbe 100644 --- a/backend/web/src/client_ip.rs +++ b/backend/web/src/client_ip.rs @@ -15,6 +15,13 @@ use axum::http::HeaderMap; use ipnet::IpNet; use std::net::IpAddr; +/// Request extension carrying the resolved client IP. Inserted by the +/// `authorize` and `authorize_optional` middleware so downstream handlers +/// (cache Basic-auth, badge token URL, evaluation token URL) can re-check +/// `allowed_ips` without re-deriving from peer + XFF. +#[derive(Debug, Clone, Copy)] +pub struct ClientIp(pub IpAddr); + pub fn resolve_client_ip(headers: &HeaderMap, peer: IpAddr, trusted_proxies: &[IpNet]) -> IpAddr { let peer = normalize(peer); if !in_any(peer, trusted_proxies) { diff --git a/backend/web/src/endpoints/badges.rs b/backend/web/src/endpoints/badges.rs index ae85f6a3..517b7fc9 100644 --- a/backend/web/src/endpoints/badges.rs +++ b/backend/web/src/endpoints/badges.rs @@ -237,18 +237,27 @@ async fn resolve_badge_user( maybe_user: Option, api_key: MaybeApiKey, token: Option, + client_ip: std::net::IpAddr, ) -> Result<(Option, Option), WebError> { match token { Some(tok) => { let decoded = crate::authorization::decode_jwt(State(Arc::clone(state)), tok) .await .map_err(|_| WebError::unauthorized("Invalid token"))?; + if let Some(ctx) = decoded.api_key_context() + && !gradient_core::ip_allowlist::is_allowed(client_ip, &ctx.allowed_ips) + { + return Err(WebError::forbidden_with( + crate::error::ErrorCode::FORBIDDEN_SOURCE_IP, + "API key not allowed from this source IP", + )); + } let user = EUser::find_by_id(decoded.user_id()) .one(&state.web_db) .await?; - Ok((user, decoded.api_key_context().copied())) + Ok((user, decoded.api_key_context().cloned())) } - None => Ok((maybe_user, api_key.as_ref().copied())), + None => Ok((maybe_user, api_key.as_ref().cloned())), } } @@ -368,6 +377,9 @@ pub async fn get_project_badge( state: State>, axum::Extension(MaybeUser(maybe_user)): axum::Extension, axum::Extension(api_key): axum::Extension, + axum::Extension(crate::client_ip::ClientIp(client_ip)): axum::Extension< + crate::client_ip::ClientIp, + >, Path((organization, project)): Path<(String, String)>, Query(params): Query, ) -> Result { @@ -376,7 +388,7 @@ pub async fn get_project_badge( .or_not_found("Organization")?; let (resolved_user, resolved_key) = - resolve_badge_user(&state, maybe_user, api_key, params.token).await?; + resolve_badge_user(&state, maybe_user, api_key, params.token, client_ip).await?; check_badge_org_access(&state, &organization, &resolved_user, resolved_key.as_ref()).await?; let project = EProject::find() diff --git a/backend/web/src/endpoints/caches/build_log.rs b/backend/web/src/endpoints/caches/build_log.rs index 8c730e63..9fd73abf 100644 --- a/backend/web/src/endpoints/caches/build_log.rs +++ b/backend/web/src/endpoints/caches/build_log.rs @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use super::helpers::CacheContext; +use super::helpers::{CacheContext, cache_client_ip}; +use super::narinfo::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::body::Body; use axum::extract::{Path, State}; @@ -17,10 +18,12 @@ use std::sync::Arc; pub async fn log( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache, drv)): Path<(String, String)>, ) -> WebResult { - let ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; let Ok((drv_hash, drv_name)) = parse_drv_hash_name(&drv) else { return Err(WebError::not_found("Log")); diff --git a/backend/web/src/endpoints/caches/helpers.rs b/backend/web/src/endpoints/caches/helpers.rs index d1be23d6..f22ab5c3 100644 --- a/backend/web/src/endpoints/caches/helpers.rs +++ b/backend/web/src/endpoints/caches/helpers.rs @@ -5,14 +5,17 @@ */ use crate::authorization::decode_jwt; -use crate::error::{WebError, WebResult}; +use crate::client_ip::resolve_client_ip; +use crate::error::{ErrorCode, WebError, WebResult}; use crate::helpers::OptionExt; use axum::extract::State; use axum::http::HeaderMap; use base64::Engine; +use gradient_core::ip_allowlist::is_allowed as ip_allowed; use gradient_core::nix_hash::{normalize_nar_hash, strip_hash_algo}; use gradient_core::sources::get_path_from_derivation_output; use gradient_core::types::*; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use sea_orm::ActiveValue::Set; use sea_orm::{ ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, PaginatorTrait, @@ -23,21 +26,44 @@ use tracing::error; /// Extracts HTTP Basic Auth credentials and resolves them to a user. /// The password field is treated as a JWT or API key (the username is ignored). -async fn try_authenticate_basic(state: &Arc, headers: &HeaderMap) -> Option { - let auth = headers.get(axum::http::header::AUTHORIZATION)?; - let val = auth.to_str().ok()?; - let encoded = val.strip_prefix("Basic ")?; - let bytes = base64::engine::general_purpose::STANDARD - .decode(encoded) - .ok()?; - let creds = String::from_utf8(bytes).ok()?; - let password = creds.split_once(':').map(|(_, p)| p)?.to_string(); - let decoded = decode_jwt(State(Arc::clone(state)), password).await.ok()?; - EUser::find_by_id(decoded.user_id()) +/// Returns `Err(forbidden)` when an API-key allowlist rejects `client_ip`. +async fn try_authenticate_basic( + state: &Arc, + headers: &HeaderMap, + client_ip: IpAddr, +) -> WebResult> { + let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else { + return Ok(None); + }; + let Ok(val) = auth.to_str() else { return Ok(None) }; + let Some(encoded) = val.strip_prefix("Basic ") else { + return Ok(None); + }; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else { + return Ok(None); + }; + let Ok(creds) = String::from_utf8(bytes) else { + return Ok(None); + }; + let Some(password) = creds.split_once(':').map(|(_, p)| p.to_string()) else { + return Ok(None); + }; + let Ok(decoded) = decode_jwt(State(Arc::clone(state)), password).await else { + return Ok(None); + }; + if let Some(ctx) = decoded.api_key_context() + && !ip_allowed(client_ip, &ctx.allowed_ips) + { + return Err(WebError::forbidden_with( + ErrorCode::FORBIDDEN_SOURCE_IP, + "API key not allowed from this source IP", + )); + } + Ok(EUser::find_by_id(decoded.user_id()) .one(&state.web_db) .await .ok() - .flatten() + .flatten()) } /// Returns true if `user` is allowed to read `cache`. @@ -86,13 +112,14 @@ async fn user_can_access_cache(state: &Arc, cache: &MCache, user: & async fn require_cache_auth( state: &Arc, headers: &HeaderMap, + client_ip: IpAddr, cache: &MCache, ) -> WebResult<()> { if cache.public { return Ok(()); } - let maybe_user = try_authenticate_basic(state, headers).await; + let maybe_user = try_authenticate_basic(state, headers, client_ip).await?; match maybe_user { Some(user) if user_can_access_cache(state, cache, &user).await => Ok(()), _ => Err(WebError::unauthorized( @@ -385,6 +412,7 @@ impl CacheContext { pub(super) async fn load( state: &Arc, headers: &HeaderMap, + client_ip: IpAddr, cache_name: String, ) -> WebResult { let cache = ECache::find() @@ -397,12 +425,23 @@ impl CacheContext { return Err(WebError::bad_request("Cache is disabled")); } - require_cache_auth(state, headers, &cache).await?; + require_cache_auth(state, headers, client_ip, &cache).await?; Ok(Self { cache }) } } +pub(super) fn cache_client_ip( + state: &Arc, + headers: &HeaderMap, + peer: Option, +) -> IpAddr { + let peer_ip = peer + .map(|p| p.ip()) + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + resolve_client_ip(headers, peer_ip, &state.config.network.trusted_proxies) +} + /// Query extractor for the `?json` flag used by text-format cache endpoints /// (`nix-cache-info`, `gradient-cache-info`, `.narinfo`). Any presence of /// `?json` (with or without a value) selects the JSON response variant. diff --git a/backend/web/src/endpoints/caches/nar.rs b/backend/web/src/endpoints/caches/nar.rs index 4801ad35..837cb786 100644 --- a/backend/web/src/endpoints/caches/nar.rs +++ b/backend/web/src/endpoints/caches/nar.rs @@ -4,7 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use super::helpers::CacheContext; +use super::helpers::{CacheContext, cache_client_ip}; +use super::narinfo::OptionalPeer; use crate::error::{WebError, WebResult}; use crate::helpers::OptionExt; use axum::body::Body; @@ -19,6 +20,7 @@ use uuid::Uuid; pub async fn nar( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache, path)): Path<(String, String)>, ) -> WebResult { @@ -29,7 +31,8 @@ pub async fn nar( return Err(WebError::not_found("Path")); } - let ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; let compressed = super::helpers::fetch_nar_bytes(&state, &path_hash).await?; let effective_hash = resolve_effective_hash_db(&state.web_db, &path_hash).await?; @@ -48,10 +51,12 @@ pub async fn nar( pub async fn upstream_nar( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache_name, upstream_id, path)): Path<(String, Uuid, String)>, ) -> WebResult { - let ctx = CacheContext::load(&state, &headers, cache_name).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let ctx = CacheContext::load(&state, &headers, client_ip, cache_name).await?; let upstream = ECacheUpstream::find_by_id(upstream_id) .filter(CCacheUpstream::Cache.eq(ctx.cache.id)) diff --git a/backend/web/src/endpoints/caches/narinfo.rs b/backend/web/src/endpoints/caches/narinfo.rs index 9d1e0d4d..af0dcd11 100644 --- a/backend/web/src/endpoints/caches/narinfo.rs +++ b/backend/web/src/endpoints/caches/narinfo.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use super::helpers::{CacheContext, JsonFlag, get_nar_by_hash}; +use super::helpers::{CacheContext, JsonFlag, cache_client_ip, get_nar_by_hash}; use crate::error::{WebError, WebResult}; use axum::extract::connect_info::MockConnectInfo; use axum::extract::{ConnectInfo, FromRequestParts, Path, Query, State}; @@ -60,7 +60,8 @@ pub async fn nix_cache_info( Path(cache): Path, Query(flag): Query, ) -> WebResult { - let ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; let priority = match (ctx.cache.local_priority, peer) { (Some(p), Some(addr)) if p != 0 => { @@ -93,11 +94,13 @@ pub async fn nix_cache_info( pub async fn gradient_cache_info( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path(cache): Path, Query(flag): Query, ) -> WebResult { - CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + CacheContext::load(&state, &headers, client_ip, cache).await?; let info = GradientCacheInfo { gradient_version: env!("CARGO_PKG_VERSION").to_string(), @@ -113,6 +116,7 @@ pub async fn gradient_cache_info( pub async fn path( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache, path)): Path<(String, String)>, Query(flag): Query, @@ -124,7 +128,8 @@ pub async fn path( return Err(WebError::not_found("Path")); } - let ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; if let Ok(path_info) = get_nar_by_hash(Arc::clone(&state), ctx.cache.clone(), path_hash.clone()).await diff --git a/backend/web/src/endpoints/caches/narlist.rs b/backend/web/src/endpoints/caches/narlist.rs index cd88bea1..35f7be42 100644 --- a/backend/web/src/endpoints/caches/narlist.rs +++ b/backend/web/src/endpoints/caches/narlist.rs @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use super::helpers::{CacheContext, fetch_nar_bytes}; +use super::helpers::{CacheContext, cache_client_ip, fetch_nar_bytes}; +use super::narinfo::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::Json; use axum::extract::{Path, State}; @@ -46,10 +47,12 @@ pub enum FileTree { pub async fn ls( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache, hash)): Path<(String, String)>, ) -> WebResult { - let _ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let _ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; let compressed = fetch_nar_bytes(&state, &hash).await?; let decompressed = zstd::decode_all(io::Cursor::new(&compressed)) diff --git a/backend/web/src/endpoints/caches/serve.rs b/backend/web/src/endpoints/caches/serve.rs index 7a9dee12..d425918d 100644 --- a/backend/web/src/endpoints/caches/serve.rs +++ b/backend/web/src/endpoints/caches/serve.rs @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -use super::helpers::{CacheContext, fetch_nar_bytes}; +use super::helpers::{CacheContext, cache_client_ip, fetch_nar_bytes}; +use super::narinfo::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::body::Body; use axum::extract::{Path, State}; @@ -15,10 +16,12 @@ use std::sync::Arc; pub async fn serve( state: State>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, Path((cache, hash, rel_path)): Path<(String, String, String)>, ) -> WebResult { - let _ctx = CacheContext::load(&state, &headers, cache).await?; + let client_ip = cache_client_ip(&state, &headers, peer); + let _ctx = CacheContext::load(&state, &headers, client_ip, cache).await?; let compressed = fetch_nar_bytes(&state, &hash).await?; match extract_path_from_nar_bytes(compressed, &rel_path).await { diff --git a/backend/web/src/endpoints/projects/evaluations.rs b/backend/web/src/endpoints/projects/evaluations.rs index 1073c08b..50a5d258 100644 --- a/backend/web/src/endpoints/projects/evaluations.rs +++ b/backend/web/src/endpoints/projects/evaluations.rs @@ -662,6 +662,7 @@ pub async fn get_entry_point_download( state: State>, Extension(MaybeUser(maybe_user)): Extension, Extension(api_key): Extension, + Extension(crate::client_ip::ClientIp(client_ip)): Extension, Path((organization, project)): Path<(String, String)>, Query(params): Query, ) -> Result { @@ -683,12 +684,20 @@ pub async fn get_entry_point_download( let decoded = crate::authorization::decode_jwt(State(Arc::clone(&state)), token_str) .await .map_err(|_| WebError::unauthorized("Invalid token"))?; + if let Some(ctx) = decoded.api_key_context() + && !gradient_core::ip_allowlist::is_allowed(client_ip, &ctx.allowed_ips) + { + return Err(WebError::forbidden_with( + crate::error::ErrorCode::FORBIDDEN_SOURCE_IP, + "API key not allowed from this source IP", + )); + } let user = EUser::find_by_id(decoded.user_id()) .one(&state.web_db) .await?; - (user, decoded.api_key_context().copied()) + (user, decoded.api_key_context().cloned()) } else { - (maybe_user, api_key.as_ref().copied()) + (maybe_user, api_key.as_ref().cloned()) }; if !organization.public { diff --git a/backend/web/src/error.rs b/backend/web/src/error.rs index 7efbf082..dbd767c2 100644 --- a/backend/web/src/error.rs +++ b/backend/web/src/error.rs @@ -57,6 +57,10 @@ impl ErrorCode { // 403 Forbidden pub const FORBIDDEN: Self = Self("forbidden"); pub const SUPERUSER_REQUIRED: Self = Self("superuser_required"); + pub const FORBIDDEN_SOURCE_IP: Self = Self("forbidden_source_ip"); + + // Validation-specific bad-request codes + pub const INVALID_ALLOWED_IP: Self = Self("invalid_allowed_ip"); // 404 Not Found pub const NOT_FOUND: Self = Self("not_found"); From fc461700d86cb0746463eab3bb061deebeb982c9 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:20:39 +0200 Subject: [PATCH 05/10] feat(web): enforce integration allowed_ips on inbound webhooks --- backend/web/src/client_ip.rs | 29 +++++++- backend/web/src/endpoints/caches/build_log.rs | 2 +- backend/web/src/endpoints/caches/nar.rs | 2 +- backend/web/src/endpoints/caches/narinfo.rs | 30 +------- backend/web/src/endpoints/caches/narlist.rs | 2 +- backend/web/src/endpoints/caches/serve.rs | 2 +- backend/web/src/endpoints/forge_hooks/mod.rs | 74 +++++++++++++++++-- .../web/src/endpoints/forge_hooks/trigger.rs | 20 ++++- 8 files changed, 118 insertions(+), 43 deletions(-) diff --git a/backend/web/src/client_ip.rs b/backend/web/src/client_ip.rs index fb277dbe..78515c30 100644 --- a/backend/web/src/client_ip.rs +++ b/backend/web/src/client_ip.rs @@ -11,9 +11,36 @@ //! otherwise the peer IP is returned verbatim so an internet client can't //! spoof its own apparent origin by sending the header. +use axum::extract::connect_info::MockConnectInfo; +use axum::extract::{ConnectInfo, FromRequestParts}; use axum::http::HeaderMap; +use axum::http::request::Parts; use ipnet::IpNet; -use std::net::IpAddr; +use std::convert::Infallible; +use std::net::{IpAddr, SocketAddr}; + +/// Optional peer-address extractor that yields `None` instead of 500 when +/// neither real nor mock `ConnectInfo` is wired (e.g., `axum_test` without +/// `MockConnectInfo`, ad-hoc tower stacks). +pub struct OptionalPeer(pub Option); + +impl FromRequestParts for OptionalPeer { + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + let addr = parts + .extensions + .get::>() + .map(|c| c.0) + .or_else(|| { + parts + .extensions + .get::>() + .map(|m| m.0) + }); + Ok(OptionalPeer(addr)) + } +} /// Request extension carrying the resolved client IP. Inserted by the /// `authorize` and `authorize_optional` middleware so downstream handlers diff --git a/backend/web/src/endpoints/caches/build_log.rs b/backend/web/src/endpoints/caches/build_log.rs index 9fd73abf..bc9337a2 100644 --- a/backend/web/src/endpoints/caches/build_log.rs +++ b/backend/web/src/endpoints/caches/build_log.rs @@ -4,7 +4,7 @@ */ use super::helpers::{CacheContext, cache_client_ip}; -use super::narinfo::OptionalPeer; +use crate::client_ip::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::body::Body; use axum::extract::{Path, State}; diff --git a/backend/web/src/endpoints/caches/nar.rs b/backend/web/src/endpoints/caches/nar.rs index 837cb786..1de41d44 100644 --- a/backend/web/src/endpoints/caches/nar.rs +++ b/backend/web/src/endpoints/caches/nar.rs @@ -5,7 +5,7 @@ */ use super::helpers::{CacheContext, cache_client_ip}; -use super::narinfo::OptionalPeer; +use crate::client_ip::OptionalPeer; use crate::error::{WebError, WebResult}; use crate::helpers::OptionExt; use axum::body::Body; diff --git a/backend/web/src/endpoints/caches/narinfo.rs b/backend/web/src/endpoints/caches/narinfo.rs index af0dcd11..c710177a 100644 --- a/backend/web/src/endpoints/caches/narinfo.rs +++ b/backend/web/src/endpoints/caches/narinfo.rs @@ -5,43 +5,17 @@ */ use super::helpers::{CacheContext, JsonFlag, cache_client_ip, get_nar_by_hash}; +use crate::client_ip::OptionalPeer; use crate::error::{WebError, WebResult}; -use axum::extract::connect_info::MockConnectInfo; -use axum::extract::{ConnectInfo, FromRequestParts, Path, Query, State}; -use axum::http::request::Parts; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, HeaderValue, header}; use axum::response::{IntoResponse, Response}; use gradient_core::sources::{get_hash_from_url, verify_narinfo_signature}; use gradient_core::types::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; -use std::convert::Infallible; -use std::net::SocketAddr; use std::sync::Arc; use tracing::warn; -/// Optional peer address - mirrors `ConnectInfo`'s real / `MockConnectInfo` -/// fallback, but yields `None` instead of 500 when the runtime wired neither -/// (`axum_test::TestServer` without `MockConnectInfo`, ad-hoc tower stacks). -pub struct OptionalPeer(pub Option); - -impl FromRequestParts for OptionalPeer { - type Rejection = Infallible; - - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - let addr = parts - .extensions - .get::>() - .map(|c| c.0) - .or_else(|| { - parts - .extensions - .get::>() - .map(|m| m.0) - }); - Ok(OptionalPeer(addr)) - } -} - // ── Helpers ─────────────────────────────────────────────────────────────────── fn text_response(content_type: &'static str, body: String) -> WebResult> { diff --git a/backend/web/src/endpoints/caches/narlist.rs b/backend/web/src/endpoints/caches/narlist.rs index 35f7be42..35e236d9 100644 --- a/backend/web/src/endpoints/caches/narlist.rs +++ b/backend/web/src/endpoints/caches/narlist.rs @@ -4,7 +4,7 @@ */ use super::helpers::{CacheContext, cache_client_ip, fetch_nar_bytes}; -use super::narinfo::OptionalPeer; +use crate::client_ip::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::Json; use axum::extract::{Path, State}; diff --git a/backend/web/src/endpoints/caches/serve.rs b/backend/web/src/endpoints/caches/serve.rs index d425918d..3a34a7ee 100644 --- a/backend/web/src/endpoints/caches/serve.rs +++ b/backend/web/src/endpoints/caches/serve.rs @@ -4,7 +4,7 @@ */ use super::helpers::{CacheContext, cache_client_ip, fetch_nar_bytes}; -use super::narinfo::OptionalPeer; +use crate::client_ip::OptionalPeer; use crate::error::{WebError, WebResult}; use axum::body::Body; use axum::extract::{Path, State}; diff --git a/backend/web/src/endpoints/forge_hooks/mod.rs b/backend/web/src/endpoints/forge_hooks/mod.rs index 18736e31..a39fb645 100644 --- a/backend/web/src/endpoints/forge_hooks/mod.rs +++ b/backend/web/src/endpoints/forge_hooks/mod.rs @@ -29,15 +29,18 @@ use gradient_core::ci::actions::decrypt_secret_with_file; use gradient_core::ci::{ ForgeType, IntegrationKind, verify_gitea_signature, verify_github_signature, }; +use gradient_core::ip_allowlist::is_allowed as ip_allowed; use gradient_core::types::input::load_secret; use gradient_core::types::*; use scheduler::Scheduler; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use subtle::ConstantTimeEq; use tracing::{debug, warn}; -use crate::error::{WebError, WebResult}; +use crate::client_ip::{OptionalPeer, resolve_client_ip}; +use crate::error::{ErrorCode, WebError, WebResult}; use crate::helpers::ok_json; use events::{ParsedPullRequestEvent, ParsedPushEvent, ParsedReleaseEvent}; @@ -52,6 +55,7 @@ use trigger::{ pub async fn github_app_webhook( State(state): State>, Extension(scheduler): Extension>, + OptionalPeer(peer): OptionalPeer, headers: HeaderMap, body: Bytes, ) -> WebResult>> { @@ -83,6 +87,11 @@ pub async fn github_app_webhook( return Err(WebError::unauthorized("invalid webhook signature")); } + let peer_ip = peer + .map(|p| p.ip()) + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + let client_ip = resolve_client_ip(&headers, peer_ip, &state.config.network.trusted_proxies); + let event = headers .get("X-GitHub-Event") .and_then(|v| v.to_str().ok()) @@ -97,7 +106,8 @@ pub async fn github_app_webhook( return Err(WebError::bad_request("malformed webhook payload")); }; let urls = parsed.repository_urls.clone(); - let outcome = dispatch_github_app_push(&state, &scheduler, parsed, &body).await; + let outcome = + dispatch_github_app_push(&state, &scheduler, parsed, &body, client_ip).await; WebhookResponse { event: "push".to_string(), repository_urls: urls, @@ -111,7 +121,8 @@ pub async fn github_app_webhook( return Err(WebError::bad_request("malformed webhook payload")); }; let urls = parsed.repository_urls.clone(); - let outcome = dispatch_github_app_pr(&state, &scheduler, parsed, &body).await; + let outcome = + dispatch_github_app_pr(&state, &scheduler, parsed, &body, client_ip).await; WebhookResponse { event: "pull_request".to_string(), repository_urls: urls, @@ -125,7 +136,8 @@ pub async fn github_app_webhook( return Err(WebError::bad_request("malformed webhook payload")); }; let urls = parsed.repository_urls.clone(); - let outcome = dispatch_github_app_release(&state, &scheduler, parsed, &body).await; + let outcome = + dispatch_github_app_release(&state, &scheduler, parsed, &body, client_ip).await; WebhookResponse { event: "release".to_string(), repository_urls: urls, @@ -143,7 +155,15 @@ pub async fn github_app_webhook( WebhookResponse::empty(&event) } "issue_comment" => { - trigger::handle_issue_comment(&state, &scheduler, ForgeType::GitHub, None, &body).await; + trigger::handle_issue_comment( + &state, + &scheduler, + ForgeType::GitHub, + None, + &body, + client_ip, + ) + .await; WebhookResponse::empty(&event) } other => WebhookResponse::empty(other), @@ -175,12 +195,15 @@ async fn dispatch_github_app_push( scheduler: &Arc, parsed: ParsedPushEvent, body: &[u8], + client_ip: IpAddr, ) -> WebhookTriggerOutcome { let Some(installation_id) = github_installation_id_from_body(body) else { warn!("GitHub App push: missing installation_id"); return WebhookTriggerOutcome::default(); }; - let targets = resolve_github_app_targets(state, installation_id, &parsed.repository_urls).await; + let targets = + resolve_github_app_targets(state, installation_id, &parsed.repository_urls, client_ip) + .await; if targets.is_empty() { warn!( installation_id, @@ -220,12 +243,15 @@ async fn dispatch_github_app_pr( scheduler: &Arc, parsed: ParsedPullRequestEvent, body: &[u8], + client_ip: IpAddr, ) -> WebhookTriggerOutcome { let Some(installation_id) = github_installation_id_from_body(body) else { warn!("GitHub App pull_request: missing installation_id"); return WebhookTriggerOutcome::default(); }; - let targets = resolve_github_app_targets(state, installation_id, &parsed.repository_urls).await; + let targets = + resolve_github_app_targets(state, installation_id, &parsed.repository_urls, client_ip) + .await; if targets.is_empty() { warn!( installation_id, @@ -274,12 +300,15 @@ async fn dispatch_github_app_release( scheduler: &Arc, parsed: ParsedReleaseEvent, body: &[u8], + client_ip: IpAddr, ) -> WebhookTriggerOutcome { let Some(installation_id) = github_installation_id_from_body(body) else { warn!("GitHub App release: missing installation_id"); return WebhookTriggerOutcome::default(); }; - let targets = resolve_github_app_targets(state, installation_id, &parsed.repository_urls).await; + let targets = + resolve_github_app_targets(state, installation_id, &parsed.repository_urls, client_ip) + .await; if targets.is_empty() { warn!( installation_id, @@ -316,6 +345,7 @@ async fn dispatch_github_app_release( pub async fn forge_webhook( State(state): State>, Extension(scheduler): Extension>, + OptionalPeer(peer): OptionalPeer, Path((forge, org_name, integration_name)): Path<(String, String, String)>, headers: HeaderMap, body: Bytes, @@ -372,6 +402,28 @@ pub async fn forge_webhook( return Err(WebError::unauthorized("invalid webhook signature")); } + let allowlist = integration.allowed_ips.clone().unwrap_or_default(); + if !allowlist.is_empty() { + let peer_ip = peer + .map(|p| p.ip()) + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + let client_ip = + resolve_client_ip(&headers, peer_ip, &state.config.network.trusted_proxies); + if !ip_allowed(client_ip, &allowlist) { + warn!( + org = %org_name, + forge = %forge, + integration = %integration_name, + %client_ip, + "Forge webhook: source IP not allowed", + ); + return Err(WebError::forbidden_with( + ErrorCode::FORBIDDEN_SOURCE_IP, + "Webhook source IP not allowed", + )); + } + } + let integration_id = integration.id; let event_type = forge_event_type(forge_type, &headers); @@ -476,12 +528,18 @@ pub async fn forge_webhook( } } ForgeEvent::Comment => { + let peer_ip = peer + .map(|p| p.ip()) + .unwrap_or_else(|| IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + let client_ip = + resolve_client_ip(&headers, peer_ip, &state.config.network.trusted_proxies); trigger::handle_issue_comment( &state, &scheduler, forge_type, Some(integration_id), &body, + client_ip, ) .await; WebhookResponse::empty("comment") diff --git a/backend/web/src/endpoints/forge_hooks/trigger.rs b/backend/web/src/endpoints/forge_hooks/trigger.rs index f4ae859a..8c0b169f 100644 --- a/backend/web/src/endpoints/forge_hooks/trigger.rs +++ b/backend/web/src/endpoints/forge_hooks/trigger.rs @@ -168,8 +168,10 @@ pub(super) async fn resolve_github_app_targets( state: &Arc, installation_id: i64, repository_urls: &[String], + client_ip: std::net::IpAddr, ) -> Vec { use gradient_core::ci::IntegrationKind; + use gradient_core::ip_allowlist::is_allowed as ip_allowed; use std::collections::HashSet; let candidate_orgs = EOrganization::find() @@ -215,7 +217,19 @@ pub(super) async fn resolve_github_app_targets( .ok() .flatten(); match integration { - Some(i) => integrations.push(i.id), + Some(i) => { + let allowlist = i.allowed_ips.clone().unwrap_or_default(); + if !ip_allowed(client_ip, &allowlist) { + warn!( + org_id = %org.id, + integration_id = %i.id, + %client_ip, + "resolve_github_app_targets: source IP not allowed, skipping integration" + ); + continue; + } + integrations.push(i.id); + } None => warn!( org_id = %org.id, "resolve_github_app_targets: org has matching project but no inbound github integration row" @@ -983,6 +997,7 @@ pub(super) async fn handle_issue_comment( forge: ForgeType, integration_id: Option, body: &[u8], + client_ip: std::net::IpAddr, ) { let payload: CommentPayload = match serde_json::from_slice(body) { Ok(p) => p, @@ -1061,7 +1076,8 @@ pub(super) async fn handle_issue_comment( format!("https://github.com/{owner_repo}.git"), format!("git@github.com:{owner_repo}.git"), ]; - let targets = resolve_github_app_targets(state, installation_id, &repo_urls).await; + let targets = + resolve_github_app_targets(state, installation_id, &repo_urls, client_ip).await; if targets.is_empty() { warn!(installation_id, %owner_repo, "comment webhook (github): no integration matched"); return; From 8bd2ea3933194346b8173cf2556fe1a4285dcb9c Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:23:13 +0200 Subject: [PATCH 06/10] feat(web): accept allowed_ips on api key + integration CRUD --- .../web/src/endpoints/orgs/integrations.rs | 33 ++++++++++++++++++- backend/web/src/endpoints/user.rs | 33 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/backend/web/src/endpoints/orgs/integrations.rs b/backend/web/src/endpoints/orgs/integrations.rs index 664efd9a..fe148736 100644 --- a/backend/web/src/endpoints/orgs/integrations.rs +++ b/backend/web/src/endpoints/orgs/integrations.rs @@ -45,6 +45,7 @@ pub struct IntegrationResponse { pub endpoint_url: Option, pub has_secret: bool, pub has_access_token: bool, + pub allowed_ips: Vec, pub created_by: UserId, pub created_at: chrono::NaiveDateTime, } @@ -61,12 +62,31 @@ impl From for IntegrationResponse { endpoint_url: m.endpoint_url, has_secret: m.secret.is_some(), has_access_token: m.access_token.is_some(), + allowed_ips: m.allowed_ips.unwrap_or_default(), created_by: m.created_by, created_at: m.created_at, } } } +fn normalize_allowed_ips(raw: Option>) -> Result>, WebError> { + let Some(entries) = raw else { return Ok(None) }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + let canon = gradient_core::ip_allowlist::normalize_entry(&e).map_err(|err| { + WebError::bad_request_with( + crate::error::ErrorCode::INVALID_ALLOWED_IP, + format!("invalid allowed_ips entry '{e}': {err}"), + ) + })?; + out.push(canon); + } + Ok(Some(out)) +} + #[derive(Deserialize, Debug)] pub struct CreateIntegrationRequest { pub name: String, @@ -83,6 +103,9 @@ pub struct CreateIntegrationRequest { pub endpoint_url: Option, /// Plaintext API token for outbound integrations. pub access_token: Option, + /// CIDR strings; only inbound webhooks from these sources are accepted. + #[serde(default)] + pub allowed_ips: Option>, } /// Credential-free integration handle. Returned by the summaries endpoint @@ -120,6 +143,8 @@ pub struct PatchIntegrationRequest { pub secret: Option, /// When present, replaces the stored access token. Empty string clears it. pub access_token: Option, + /// Wholesale replacement; `[]` clears the allowlist. + pub allowed_ips: Option>, } // ── Helpers ────────────────────────────────────────────────────────────────── @@ -307,6 +332,8 @@ pub async fn put_integration( .map(|s| s.to_string()) .unwrap_or_else(|| body.name.clone()); + let allowed_ips = normalize_allowed_ips(body.allowed_ips.clone())? + .and_then(|v| if v.is_empty() { None } else { Some(v) }); let integration = AIntegration { id: Set(IntegrationId::now_v7()), organization: Set(org.id), @@ -317,7 +344,7 @@ pub async fn put_integration( secret: Set(encrypted_secret), endpoint_url: Set(endpoint_url), access_token: Set(encrypted_token), - allowed_ips: Set(None), + allowed_ips: Set(allowed_ips), created_by: Set(user.id), created_at: Set(gradient_core::types::now()), }; @@ -453,6 +480,10 @@ pub async fn patch_integration( }); } + if let Some(canon) = normalize_allowed_ips(body.allowed_ips)? { + active.allowed_ips = Set(if canon.is_empty() { None } else { Some(canon) }); + } + let updated = active.update(&state.web_db).await?; Ok(ok_json(IntegrationResponse::from(updated))) diff --git a/backend/web/src/endpoints/user.rs b/backend/web/src/endpoints/user.rs index 860e944a..7ac8ac74 100644 --- a/backend/web/src/endpoints/user.rs +++ b/backend/web/src/endpoints/user.rs @@ -57,6 +57,9 @@ pub struct CreateApiKeyRequest { /// Optional cache name to pin the key to. Mutually exclusive with `organization`. #[serde(default)] pub cache: Option, + /// CIDR strings the key may be used from. Empty or omitted = any source. + #[serde(default)] + pub allowed_ips: Option>, } #[derive(Deserialize, Debug)] @@ -69,6 +72,9 @@ pub struct PatchApiKeyRequest { /// pin, `Some(null)` (i.e. JSON null) to unpin. #[serde(default, deserialize_with = "deserialize_optional_field")] pub organization: Option>, + /// Wholesale replacement; `[]` clears the allowlist. + #[serde(default)] + pub allowed_ips: Option>, } fn deserialize_optional_field<'de, T, D>(de: D) -> Result>, D::Error> @@ -91,6 +97,8 @@ pub struct ApiKeyInfo { pub last_used_at: Option, pub expires_at: Option, pub revoked_at: Option, + /// CIDR allowlist (canonicalized). Empty list = any source. + pub allowed_ips: Vec, } #[derive(Serialize, Debug)] @@ -326,9 +334,28 @@ fn api_key_info( last_used_at: last_used_or_none(key.last_used_at), expires_at: fmt_opt_dt(key.expires_at), revoked_at: fmt_opt_dt(key.revoked_at), + allowed_ips: key.allowed_ips.unwrap_or_default(), } } +fn normalize_allowed_ips(raw: Option>) -> Result>, WebError> { + let Some(entries) = raw else { return Ok(None) }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut out = Vec::with_capacity(entries.len()); + for e in entries { + let canon = gradient_core::ip_allowlist::normalize_entry(&e).map_err(|err| { + WebError::bad_request_with( + crate::error::ErrorCode::INVALID_ALLOWED_IP, + format!("invalid allowed_ips entry '{e}': {err}"), + ) + })?; + out.push(canon); + } + Ok(Some(out)) +} + pub async fn get_key_permissions() -> WebResult>> { Ok(ok_json(ApiKeyPermissionsResponse { available_permissions: available_permissions(), @@ -418,6 +445,7 @@ pub async fn post_keys( (m, org, None) }; + let allowed_ips = normalize_allowed_ips(body.allowed_ips.clone())?; let raw_key = generate_api_key(); let api_key = AApi { id: Set(ApiId::now_v7()), @@ -432,7 +460,7 @@ pub async fn post_keys( permission: Set(mask), organization: Set(org_pin), cache: Set(cache_pin), - allowed_ips: Set(None), + allowed_ips: Set(allowed_ips), }; let inserted = api_key.insert(&state.web_db).await?; @@ -518,6 +546,9 @@ pub async fn patch_key( new_org = resolve_org_pin(&state, user.id, maybe_name).await?; active.organization = Set(new_org); } + if let Some(canon) = normalize_allowed_ips(body.allowed_ips)? { + active.allowed_ips = Set(if canon.is_empty() { None } else { Some(canon) }); + } let updated = active.update(&state.web_db).await?; From 9162f36ccfca4823edf30ecedf1d6ad12b1c421c Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:23:48 +0200 Subject: [PATCH 07/10] feat(core): set User-Agent on S3 client --- backend/core/src/storage/nar.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/core/src/storage/nar.rs b/backend/core/src/storage/nar.rs index 3ea61111..f894c20e 100644 --- a/backend/core/src/storage/nar.rs +++ b/backend/core/src/storage/nar.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use futures::StreamExt as _; use futures::stream::BoxStream; pub use object_store::{MultipartUpload, WriteMultipart}; -use object_store::{ObjectStore, ObjectStoreExt as _, PutPayload, path::Path}; +use object_store::{ClientOptions, ObjectStore, ObjectStoreExt as _, PutPayload, path::Path}; use std::sync::Arc; /// Unified NAR file storage abstraction over local disk or an S3-compatible backend. @@ -71,7 +71,10 @@ impl NarStore { ) -> Result { let mut builder = object_store::aws::AmazonS3Builder::new() .with_bucket_name(bucket) - .with_region(region); + .with_region(region) + .with_client_options(ClientOptions::new().with_user_agent( + crate::http::user_agent().parse().expect("static UA is valid"), + )); if let Some(ep) = endpoint { builder = builder From f480e781a3586ae64055c70f97741304b45a4c14 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:27:02 +0200 Subject: [PATCH 08/10] feat(web-ui): allowed_ips input on api key + integration forms --- .../src/app/core/models/integration.model.ts | 3 +++ frontend/src/app/core/models/user.model.ts | 1 + .../src/app/core/services/user.service.ts | 5 ++++- .../integrations/integrations.component.html | 10 +++++++++ .../integrations/integrations.component.ts | 17 +++++++++++++++ .../settings/api-keys/api-keys.component.html | 15 +++++++++++++ .../settings/api-keys/api-keys.component.ts | 21 +++++++++++++++++-- 7 files changed, 69 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/core/models/integration.model.ts b/frontend/src/app/core/models/integration.model.ts index 8d432215..f78c15c6 100644 --- a/frontend/src/app/core/models/integration.model.ts +++ b/frontend/src/app/core/models/integration.model.ts @@ -18,6 +18,7 @@ export interface Integration { endpoint_url: string | null; has_secret: boolean; has_access_token: boolean; + allowed_ips: string[]; created_by: string; created_at: string; } @@ -40,6 +41,7 @@ export interface CreateIntegrationRequest { secret?: string; endpoint_url?: string; access_token?: string; + allowed_ips?: string[]; } export interface PatchIntegrationRequest { @@ -49,4 +51,5 @@ export interface PatchIntegrationRequest { secret?: string; endpoint_url?: string; access_token?: string; + allowed_ips?: string[]; } diff --git a/frontend/src/app/core/models/user.model.ts b/frontend/src/app/core/models/user.model.ts index be12a3b7..23e202f3 100644 --- a/frontend/src/app/core/models/user.model.ts +++ b/frontend/src/app/core/models/user.model.ts @@ -34,6 +34,7 @@ export interface ApiKey { last_used_at: string | null; expires_at: string | null; revoked_at: string | null; + allowed_ips: string[]; } export interface Session { diff --git a/frontend/src/app/core/services/user.service.ts b/frontend/src/app/core/services/user.service.ts index 9ec7bad2..8eba73b0 100644 --- a/frontend/src/app/core/services/user.service.ts +++ b/frontend/src/app/core/services/user.service.ts @@ -42,6 +42,7 @@ export class UserService { permissions: string[] = ['viewOrg'], organization: string | null = null, cache: string | null = null, + allowedIps: string[] = [], ): Observable { const body: { name: string; @@ -49,7 +50,8 @@ export class UserService { permissions: string[]; organization: string | null; cache: string | null; - } = { name, permissions, organization, cache }; + allowed_ips: string[]; + } = { name, permissions, organization, cache, allowed_ips: allowedIps }; if (expiresInDays !== null && expiresInDays !== undefined) { body.expires_in_days = expiresInDays; } @@ -63,6 +65,7 @@ export class UserService { permissions?: string[]; organization?: string | null; cache?: string | null; + allowed_ips?: string[]; }, ): Observable { return this.api.patch(`user/keys/${apiId}`, body); diff --git a/frontend/src/app/features/organizations/integrations/integrations.component.html b/frontend/src/app/features/organizations/integrations/integrations.component.html index 1ac864d2..96a7ed10 100644 --- a/frontend/src/app/features/organizations/integrations/integrations.component.html +++ b/frontend/src/app/features/organizations/integrations/integrations.component.html @@ -255,6 +255,11 @@

No integrations

Copy this into your forge's webhook secret field. Store it now - it will not be shown again. +
+ + + One CIDR per line. Leave blank to accept webhooks from any source. +
} @else {
@@ -322,6 +327,11 @@

No integrations

}
+
+ + + One CIDR per line. Leave blank to accept webhooks from any source. +
} @else {
diff --git a/frontend/src/app/features/organizations/integrations/integrations.component.ts b/frontend/src/app/features/organizations/integrations/integrations.component.ts index e632038a..829545f2 100644 --- a/frontend/src/app/features/organizations/integrations/integrations.component.ts +++ b/frontend/src/app/features/organizations/integrations/integrations.component.ts @@ -93,6 +93,7 @@ export class IntegrationsComponent implements OnInit { endpoint_url: string; secret: string; access_token: string; + allowed_ips: string; } = { name: '', display_name: '', @@ -101,6 +102,7 @@ export class IntegrationsComponent implements OnInit { endpoint_url: '', secret: '', access_token: '', + allowed_ips: '', }; githubAppAvailable = computed(() => this.organization()?.github_app_available === true); @@ -172,11 +174,19 @@ export class IntegrationsComponent implements OnInit { endpoint_url: '', secret: '', access_token: '', + allowed_ips: '', }; this.errorMessage.set(null); this.showCreateDialog.set(true); } + private parseAllowedIps(): string[] { + return this.formData.allowed_ips + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + generateSecret(): void { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -199,6 +209,7 @@ export class IntegrationsComponent implements OnInit { } if (this.formData.kind === 'inbound') { if (this.formData.secret.trim()) body.secret = this.formData.secret.trim(); + body.allowed_ips = this.parseAllowedIps(); } else { if (this.formData.endpoint_url.trim()) body.endpoint_url = this.formData.endpoint_url.trim(); if (this.formData.access_token.trim()) body.access_token = this.formData.access_token.trim(); @@ -226,6 +237,7 @@ export class IntegrationsComponent implements OnInit { endpoint_url: integration.endpoint_url ?? '', secret: '', access_token: '', + allowed_ips: (integration.allowed_ips ?? []).join('\n'), }; this.errorMessage.set(null); this.showEditDialog.set(true); @@ -257,6 +269,11 @@ export class IntegrationsComponent implements OnInit { if (this.formData.secret.trim()) { body.secret = this.formData.secret.trim(); } + const allowed = this.parseAllowedIps(); + const current = (target.allowed_ips ?? []).join('\n'); + if (this.formData.allowed_ips !== current) { + body.allowed_ips = allowed; + } } this.integrationsService.patchOrgIntegration(this.orgName, target.id, body).subscribe({ next: () => { diff --git a/frontend/src/app/features/settings/api-keys/api-keys.component.html b/frontend/src/app/features/settings/api-keys/api-keys.component.html index 9b39a745..980358cc 100644 --- a/frontend/src/app/features/settings/api-keys/api-keys.component.html +++ b/frontend/src/app/features/settings/api-keys/api-keys.component.html @@ -190,6 +190,21 @@

No API keys

} +
+ + +
+
diff --git a/frontend/src/app/features/settings/api-keys/api-keys.component.ts b/frontend/src/app/features/settings/api-keys/api-keys.component.ts index a4d316bf..ee9e70d6 100644 --- a/frontend/src/app/features/settings/api-keys/api-keys.component.ts +++ b/frontend/src/app/features/settings/api-keys/api-keys.component.ts @@ -88,6 +88,7 @@ export class ApiKeysComponent implements OnInit { formScope: ScopeType = 'none'; formOrganization: string | null = null; formCache: string | null = null; + formAllowedIps = ''; ngOnInit(): void { this.loadKeys(); @@ -136,6 +137,7 @@ export class ApiKeysComponent implements OnInit { this.formCache = null; this.formPermissions = this.permissionTemplate(false); this.formPermissions['viewOrg'] = true; + this.formAllowedIps = ''; this.errorMessage.set(null); this.showDialog.set(true); } @@ -159,10 +161,18 @@ export class ApiKeysComponent implements OnInit { this.formOrganization = null; this.formCache = null; } + this.formAllowedIps = (key.allowed_ips ?? []).join('\n'); this.errorMessage.set(null); this.showDialog.set(true); } + private parseAllowedIps(): string[] { + return this.formAllowedIps + .split('\n') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + onScopeChange(): void { this.formOrganization = null; this.formCache = null; @@ -203,11 +213,18 @@ export class ApiKeysComponent implements OnInit { } const organization = this.formScope === 'organization' ? this.formOrganization : null; const cache = this.formScope === 'cache' ? this.formCache : null; + const allowedIps = this.parseAllowedIps(); const editing = this.editingKey(); if (editing) { this.saving.set(true); this.userService - .updateApiKey(editing.id, { name, permissions: perms, organization, cache }) + .updateApiKey(editing.id, { + name, + permissions: perms, + organization, + cache, + allowed_ips: allowedIps, + }) .subscribe({ next: () => { this.saving.set(false); @@ -222,7 +239,7 @@ export class ApiKeysComponent implements OnInit { } else { this.creating.set(true); this.userService - .createApiKey(name, this.formExpiresInDays, perms, organization, cache) + .createApiKey(name, this.formExpiresInDays, perms, organization, cache, allowedIps) .subscribe({ next: (keyValue) => { this.creating.set(false); From b3019ef1a6331b1e566537174490987c43dee04e Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:29:12 +0200 Subject: [PATCH 09/10] docs: allowed_ips field on api keys and integrations --- docs/gradient-api.yaml | 45 +++++++++++++++++++++++++++++++++++ docs/src/tests.md | 17 +++++++++++++ docs/src/usage/api.md | 23 ++++++++++++++++++ docs/src/usage/integration.md | 23 ++++++++++++++++++ 4 files changed, 108 insertions(+) diff --git a/docs/gradient-api.yaml b/docs/gradient-api.yaml index 4cb16e93..a9865497 100644 --- a/docs/gradient-api.yaml +++ b/docs/gradient-api.yaml @@ -5691,6 +5691,16 @@ components: Optional cache name to pin the key to. When set, the key carries cache-scoped permissions and is rejected for any other cache. Mutually exclusive with `organization`. + allowed_ips: + type: array + items: { type: string } + nullable: true + description: |- + CIDR strings (or bare IPs, auto-promoted to `/32` / `/128`) the key + may be used from. Empty array or `null` means "any source". + Requests whose resolved client IP does not fall in any listed CIDR + are rejected with `403 forbidden_source_ip`. + example: ["10.0.0.0/8", "203.0.113.5"] ApiKeyInfo: type: object @@ -5723,6 +5733,12 @@ components: type: string format: date-time nullable: true + allowed_ips: + type: array + items: { type: string } + description: |- + Canonical CIDR strings the key may be used from. Empty list means + any source is allowed. PatchApiKeyRequest: type: object @@ -5740,6 +5756,13 @@ components: description: |- Tri-state: omit to leave the pin unchanged; `null` to unpin; string to pin to a specific organization. + allowed_ips: + type: array + items: { type: string } + description: |- + Wholesale replacement of the source-IP allowlist. Omit to leave + unchanged; `[]` to clear; otherwise CIDR strings the key may be + used from. SessionInfo: type: object @@ -7326,6 +7349,14 @@ components: has_access_token: type: boolean description: Whether an API token is stored (outbound integrations). + allowed_ips: + type: array + items: { type: string } + description: |- + Source CIDRs allowed for inbound webhooks. Empty list means any + source is allowed. Inbound deliveries from outside the allowlist + are rejected with `403 forbidden_source_ip` (after signature + verification succeeds). created_by: type: string format: uuid @@ -7390,6 +7421,14 @@ components: description: >- Plaintext API token for outbound integrations. Stored encrypted. Omit for inbound integrations. + allowed_ips: + type: array + items: { type: string } + nullable: true + description: |- + CIDR strings the inbound webhook accepts deliveries from. Empty + array or `null` means "any source". Bare IPs are auto-promoted to + `/32` (v4) or `/128` (v6). Ignored for outbound integrations. PatchIntegrationRequest: type: object @@ -7417,6 +7456,12 @@ components: type: string access_token: type: string + allowed_ips: + type: array + items: { type: string } + description: |- + Wholesale replacement of the inbound source-IP allowlist. Omit to + leave unchanged; `[]` to clear; otherwise CIDR strings. TriggerType: type: string diff --git a/docs/src/tests.md b/docs/src/tests.md index d6183c88..bee7368f 100644 --- a/docs/src/tests.md +++ b/docs/src/tests.md @@ -2697,3 +2697,20 @@ Run with: `pnpm --dir frontend exec ng test --watch=false` - `project-detail.component.spec.ts → 'clears the error banner when the user retries'` — calling `dismissError()` resets `errorMessage()` to `null` and the banner disappears. + +## Source-IP allowlist (#282) + +### Backend + +Run with: `cargo test -p core --test ip_allowlist` + +- `empty_list_allows_everything` — empty allowlist is a permissive default so + existing rows keep working after migration. +- `slash_32_exact_match`, `slash_24_contains_address` — exact-host and net-mask + containment. +- `ipv4_mapped_ipv6_matches_ipv4_cidr` — dual-stack sockets compare correctly. +- `malformed_entry_is_skipped_but_others_still_count` — validation happens at + the API edge; the runtime check tolerates noise. +- `normalize_bare_ipv4_to_slash_32` / `normalize_bare_ipv6_to_slash_128` / + `normalize_keeps_cidr_unchanged` / `normalize_trims_whitespace` / + `normalize_rejects_garbage` / `normalize_rejects_empty` — write-time canonicalization. diff --git a/docs/src/usage/api.md b/docs/src/usage/api.md index 9ecbd16e..2ddab057 100644 --- a/docs/src/usage/api.md +++ b/docs/src/usage/api.md @@ -99,6 +99,29 @@ API-key-authenticated requests **cannot** create, edit, revoke, or delete API keys - only session-authenticated calls can. This prevents a leaked key from minting more powerful siblings. +### Source IP restrictions + +Each API key can carry a CIDR allowlist. Requests from outside the list are +rejected with `403 forbidden_source_ip`; an empty / omitted list allows any +source. Bare IPs are auto-normalized to `/32` (v4) or `/128` (v6). + +```bash +curl -X POST $API/user/keys \ + -H "Authorization: Bearer $SESSION" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "office-ci", + "permissions": ["triggerEvaluation"], + "allowed_ips": ["10.0.0.0/8", "203.0.113.5"] + }' +``` + +To tighten or clear the allowlist on an existing key, `PATCH` with +`"allowed_ips": [...]` (use `[]` to wipe). + +The source IP is resolved from the connection peer with `X-Forwarded-For` +honored only when the peer is in `GRADIENT_NETWORK_TRUSTED_PROXIES`. + ### Cache pinning A key may be pinned to a single cache as an alternative to organization diff --git a/docs/src/usage/integration.md b/docs/src/usage/integration.md index 2a3d67bc..b0a029f3 100644 --- a/docs/src/usage/integration.md +++ b/docs/src/usage/integration.md @@ -153,11 +153,34 @@ The `reason` field for skipped projects is one of: Non-push GitHub App events (`ping`, `installation`, `installation_repositories`, unknown) return the same envelope with `event` set accordingly and empty `queued` / `skipped` arrays. +## Source IP restrictions + +Each inbound integration can carry a CIDR allowlist (`allowed_ips`). When set, +deliveries whose source IP does not match are rejected with +`403 forbidden_source_ip` after signature verification succeeds. An empty or +omitted list allows any source. + +For the per-forge route (`POST /hooks/{forge}/{org}/{integration_name}`) the +check applies to the resolved client IP. For the GitHub App route +(`POST /hooks/github`), the check is applied per-installation: integrations +whose allowlist rejects the source IP are simply skipped, while integrations +whose list matches (or is empty) are dispatched as usual. + +Forge IP ranges to allowlist: + +- **GitHub**: published at `https://api.github.com/meta` (the `hooks` array). +- **GitLab.com**: published at . +- **Gitea / Forgejo**: typically self-hosted; allowlist your own forge's egress IPs. + +The source IP is resolved from the connection peer with `X-Forwarded-For` +honored only when the peer is in `GRADIENT_NETWORK_TRUSTED_PROXIES`. + ## Troubleshooting | Symptom | Likely cause | |-----------------------------------|-------------------------------------------------------------------------------------------------| | `401 Unauthorized` in delivery log | Secret mismatch - re-copy the secret from Gradient or rotate and reconfigure the forge. | +| `403 forbidden_source_ip` | The forge's egress IP isn't in the integration's `allowed_ips` list. Add it or clear the list. | | `404 Not Found` | Wrong organization or integration name in the URL, or `{forge}=github` (use the App webhook). | | `200 OK` but no evaluation runs | No project links to this inbound integration, or the repository URL doesn't match any project. | | `503 Service Unavailable` | The integration row has no secret set yet - paste or generate one on the Integrations page. | From 501b2a3c575bd51a87b0967b96652020bed01d95 Mon Sep 17 00:00:00 2001 From: Dennis Wuitz Date: Wed, 27 May 2026 21:59:34 +0200 Subject: [PATCH 10/10] test: backfill allowed_ips=None in fixture Models --- backend/core/src/ci/integration_lookup.rs | 1 + backend/web/src/access.rs | 2 ++ backend/web/tests/auth_hardening.rs | 5 +++++ backend/web/tests/cache_api_key_pinning.rs | 1 + backend/web/tests/forge_hooks.rs | 2 ++ backend/web/tests/orgs_integrations.rs | 3 +++ backend/web/tests/triggers.rs | 1 + 7 files changed, 15 insertions(+) diff --git a/backend/core/src/ci/integration_lookup.rs b/backend/core/src/ci/integration_lookup.rs index 26016b23..41af183f 100644 --- a/backend/core/src/ci/integration_lookup.rs +++ b/backend/core/src/ci/integration_lookup.rs @@ -131,6 +131,7 @@ mod ensure_tests { secret: None, endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user(), created_at: NaiveDateTime::default(), } diff --git a/backend/web/src/access.rs b/backend/web/src/access.rs index 3938670b..58d45223 100644 --- a/backend/web/src/access.rs +++ b/backend/web/src/access.rs @@ -960,6 +960,7 @@ mod tests { organization: org, cache_pin: None, cache_permission_mask: None, + allowed_ips: Vec::new(), } } @@ -1251,6 +1252,7 @@ mod tests { organization: None, cache_pin: None, cache_permission_mask: Some(crate::permissions::cache_view_mask()), + allowed_ips: Vec::new(), } } diff --git a/backend/web/tests/auth_hardening.rs b/backend/web/tests/auth_hardening.rs index 81cc37db..3e588554 100644 --- a/backend/web/tests/auth_hardening.rs +++ b/backend/web/tests/auth_hardening.rs @@ -191,6 +191,7 @@ fn revoked_api_key_is_rejected() { permission: gradient_core::permissions::admin_mask(), organization: None, cache: None, + allowed_ips: None, }; let s = server_with(|db| db.append_query_results([vec![key]])); @@ -222,6 +223,7 @@ fn expired_api_key_is_rejected() { permission: gradient_core::permissions::admin_mask(), organization: None, cache: None, + allowed_ips: None, }; let s = server_with(|db| db.append_query_results([vec![key]])); @@ -310,6 +312,7 @@ fn api_key_with_only_view_cannot_trigger_evaluation() { permission: mask_from(&[Permission::ViewOrg]), organization: None, cache: None, + allowed_ips: None, }; let admin_membership = entity::organization_user::Model { id: entity::ids::OrganizationUserId::now_v7(), @@ -395,6 +398,7 @@ fn api_key_pinned_to_other_org_is_invisible() { permission: mask_from(Permission::ALL), organization: Some(pinned_elsewhere), cache: None, + allowed_ips: None, }; let s = server_with(|db| { @@ -435,6 +439,7 @@ fn api_key_cannot_create_api_keys() { permission: gradient_core::permissions::admin_mask(), organization: None, cache: None, + allowed_ips: None, }; let s = server_with(|db| { diff --git a/backend/web/tests/cache_api_key_pinning.rs b/backend/web/tests/cache_api_key_pinning.rs index 03202eb3..d10652a3 100644 --- a/backend/web/tests/cache_api_key_pinning.rs +++ b/backend/web/tests/cache_api_key_pinning.rs @@ -79,6 +79,7 @@ fn pinned_api_key(raw: &str, pin: CacheId, permission: i64) -> api::Model { permission, organization: None, cache: Some(pin), + allowed_ips: None, } } diff --git a/backend/web/tests/forge_hooks.rs b/backend/web/tests/forge_hooks.rs index 56318346..9bc388b1 100644 --- a/backend/web/tests/forge_hooks.rs +++ b/backend/web/tests/forge_hooks.rs @@ -180,6 +180,7 @@ fn integration_row(secret_ciphertext: &str) -> entity::integration::Model { secret: Some(secret_ciphertext.to_string()), endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user_id(), created_at: fixture_date(), } @@ -196,6 +197,7 @@ fn github_integration_row() -> entity::integration::Model { secret: None, endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user_id(), created_at: fixture_date(), } diff --git a/backend/web/tests/orgs_integrations.rs b/backend/web/tests/orgs_integrations.rs index 6bd09257..f108067f 100644 --- a/backend/web/tests/orgs_integrations.rs +++ b/backend/web/tests/orgs_integrations.rs @@ -52,6 +52,7 @@ fn gitea_inbound_row() -> integration::Model { secret: Some("encrypted-blob".into()), endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user_id(), created_at: test_date(), } @@ -68,6 +69,7 @@ fn github_inbound_row() -> integration::Model { secret: None, endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user_id(), created_at: test_date(), } @@ -84,6 +86,7 @@ fn gitea_outbound_row() -> integration::Model { secret: None, endpoint_url: Some("https://gitea.example.com".into()), access_token: Some("encrypted-token".into()), + allowed_ips: None, created_by: user_id(), created_at: test_date(), } diff --git a/backend/web/tests/triggers.rs b/backend/web/tests/triggers.rs index c697364b..45920edd 100644 --- a/backend/web/tests/triggers.rs +++ b/backend/web/tests/triggers.rs @@ -105,6 +105,7 @@ fn github_inbound_integration_row() -> integration::Model { secret: None, endpoint_url: None, access_token: None, + allowed_ips: None, created_by: user_id(), created_at: test_date(), }