diff --git a/Cargo.lock b/Cargo.lock
index 0fdc7afc9..2ef51ead4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2389,7 +2389,6 @@ dependencies = [
"hmac",
"http-body-util",
"httpmock",
- "ipnet",
"jsonwebtoken",
"mime_guess",
"multer",
@@ -2592,7 +2591,6 @@ dependencies = [
"fabro-model",
"fabro-util",
"hex",
- "ipnet",
"serde",
"serde_json",
"sha2",
@@ -3856,9 +3854,6 @@ name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
-dependencies = [
- "serde",
-]
[[package]]
name = "iri-string"
diff --git a/apps/fabro-web/app/routes/settings-security.tsx b/apps/fabro-web/app/routes/settings-security.tsx
index 779f3f7a9..2075131c9 100644
--- a/apps/fabro-web/app/routes/settings-security.tsx
+++ b/apps/fabro-web/app/routes/settings-security.tsx
@@ -2,7 +2,6 @@ import type { ServerSettings } from "@qltysh/fabro-api-client";
import { useServerSettings } from "../lib/queries";
import {
Badge,
- Count,
Muted,
Panel,
PanelSkeleton,
@@ -10,7 +9,6 @@ import {
SettingsPageIntro,
UsernameList,
} from "../components/settings-panel";
-import { plural } from "../lib/plural";
export function meta() {
return [{ title: "Security — Fabro" }];
@@ -18,7 +16,7 @@ export function meta() {
const DESCRIPTION = (
<>
- Authentication methods and network allowlist. Edit via{" "}
+ Authentication methods and permitted GitHub usernames. Edit via{" "}
settings.toml; changes take
effect on the next server restart.
>
@@ -37,7 +35,7 @@ export default function SettingsSecurity() {
}
function SecurityPanel({ settings }: { settings: ServerSettings }) {
- const { auth, ip_allowlist } = settings.server;
+ const { auth } = settings.server;
const githubUsers = auth.github.allowed_usernames;
return (
@@ -62,18 +60,6 @@ function SecurityPanel({ settings }: { settings: ServerSettings }) {
)}
-
- 0
- ? `· ${ip_allowlist.trusted_proxy_count} trusted ${plural(ip_allowlist.trusted_proxy_count, "proxy", "proxies")}`
- : undefined
- }
- />
-
);
}
diff --git a/apps/fabro-web/app/routes/settings.tsx b/apps/fabro-web/app/routes/settings.tsx
index 67e750c28..648edbd24 100644
--- a/apps/fabro-web/app/routes/settings.tsx
+++ b/apps/fabro-web/app/routes/settings.tsx
@@ -63,7 +63,7 @@ export const navSections: NavSection[] = [
name: "Security",
href: "/settings/security",
icon: ShieldCheckIcon,
- description: "Authentication and network allowlist.",
+ description: "Authentication methods and access.",
match: (p) => p.startsWith("/settings/security"),
},
{
diff --git a/docs/public/administration/security.mdx b/docs/public/administration/security.mdx
index ec1a30bdd..27e1aeea9 100644
--- a/docs/public/administration/security.mdx
+++ b/docs/public/administration/security.mdx
@@ -15,6 +15,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
|---|---|
| **Multi-tenancy** | Not supported. Fabro is single-tenant only — there is no organization or tenant isolation. |
| **Roles or ACLs** | Not supported. All authenticated users have identical, full access to every run, workflow, and API endpoint. |
+| **Inbound source-IP allowlisting** | Not implemented in Fabro. Restrict source networks with a reverse proxy, firewall, VPN, Tailscale, platform ingress policy, or another deployment-layer control. |
| **Rate limiting** | Not implemented. The API server does not throttle requests. |
| **Custom security header policy** | Not configurable. Fabro emits default security headers, including enforced `Content-Security-Policy`, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and `Strict-Transport-Security` when HTTPS is detected. |
@@ -24,6 +25,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
- **Deploy the web app on a private network.** Use a Tailscale tailnet, VPN, or internal network to keep the web UI off the public internet.
- **Deploy the API on a private network.** Only allow access from expected hosts (the web app, CI runners, developer machines). The API binds to `127.0.0.1` by default — do not expose it with `--host 0.0.0.0` unless it is behind a firewall or private network.
+- **Enforce source-IP restrictions upstream.** Fabro does not provide inbound source-IP allowlisting. Use your reverse proxy, firewall, VPN, Tailscale ACLs, cloud load balancer, Kubernetes ingress, or platform policy to limit who can connect.
- **Use Daytona sandboxes with network controls.** When running untrusted or generated code, use the Daytona sandbox provider with `network = "block"` or a CIDR-based allow list to restrict agent egress.
### Authentication
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index aff64c205..103ab4cad 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -12280,7 +12280,6 @@ components:
- api
- web
- auth
- - ip_allowlist
- sandbox
- storage
- artifacts
@@ -12297,8 +12296,6 @@ components:
$ref: "#/components/schemas/ServerWebSettings"
auth:
$ref: "#/components/schemas/ServerAuthSettings"
- ip_allowlist:
- $ref: "#/components/schemas/ServerIpAllowlistSettings"
sandbox:
$ref: "#/components/schemas/ServerSandboxSettings"
storage:
@@ -12379,44 +12376,6 @@ components:
items:
type: string
- ServerIpAllowlistSettings:
- type: object
- required: [entries, trusted_proxy_count]
- properties:
- entries:
- type: array
- items:
- $ref: "#/components/schemas/IpAllowEntry"
- trusted_proxy_count:
- type: integer
-
- ServerIpAllowlistOverrideSettings:
- type: object
- required: [entries, trusted_proxy_count]
- properties:
- entries:
- type: ["array", "null"]
- items:
- $ref: "#/components/schemas/IpAllowEntry"
- trusted_proxy_count:
- type: ["integer", "null"]
-
- IpAllowEntry:
- oneOf:
- - $ref: "#/components/schemas/LiteralIpAllowEntry"
- - $ref: "#/components/schemas/GitHubMetaHooksEntry"
-
- LiteralIpAllowEntry:
- type: object
- required: [Literal]
- properties:
- Literal:
- type: string
-
- GitHubMetaHooksEntry:
- type: string
- enum: [GitHubMetaHooks]
-
ServerSandboxSettings:
type: object
required: [providers]
@@ -12571,16 +12530,12 @@ components:
IntegrationWebhooksSettings:
type: object
- required: [strategy, ip_allowlist]
+ required: [strategy]
properties:
strategy:
oneOf:
- $ref: "#/components/schemas/WebhookStrategy"
- type: "null"
- ip_allowlist:
- oneOf:
- - $ref: "#/components/schemas/ServerIpAllowlistOverrideSettings"
- - type: "null"
WebhookStrategy:
type: string
diff --git a/docs/public/changelog/2026-04-19.mdx b/docs/public/changelog/2026-04-19.mdx
index 93b138b11..750482f49 100644
--- a/docs/public/changelog/2026-04-19.mdx
+++ b/docs/public/changelog/2026-04-19.mdx
@@ -51,7 +51,7 @@ The install flow is also available in the embedded web app, so Docker and hosted
- New `GET /api/v1/runs/{id}/files` endpoint returns paginated run file data and diff metadata
- New `POST /api/v1/runs/{id}/archive` and `POST /api/v1/runs/{id}/unarchive` endpoints manage archived terminal runs
- `GET /api/v1/runs` accepts `include_archived=true` for listing archived runs
-- GitHub webhook handling now supports explicit source IP allowlisting with GitHub webhook range refresh
+- GitHub webhook handling added explicit source IP allowlisting with GitHub webhook range refresh (removed on 2026-05-27)
diff --git a/docs/public/changelog/2026-05-27.mdx b/docs/public/changelog/2026-05-27.mdx
new file mode 100644
index 000000000..fb81d346a
--- /dev/null
+++ b/docs/public/changelog/2026-05-27.mdx
@@ -0,0 +1,23 @@
+---
+title: "Inbound IP allowlisting removed"
+date: "2026-05-27"
+---
+
+## Inbound IP allowlisting removed
+
+Fabro no longer includes an in-process inbound source-IP allowlist for the web UI, API, static assets, or GitHub webhook routes. The former `[server.ip_allowlist]` setting and GitHub webhook overlay at `[server.integrations.github.webhooks.ip_allowlist]` have been removed from server configuration and the settings API.
+
+This is a hard removal: existing `settings.toml` files that still contain those keys now fail as unknown fields. Move any source-IP restrictions to deployment-layer controls such as a reverse proxy, firewall, VPN, Tailscale ACLs, Kubernetes ingress policy, cloud load balancer, or platform ingress.
+
+GitHub webhook HMAC signature verification is unchanged. GitHub username allowlists and sandbox egress CIDR allow lists are also unchanged.
+
+## More
+
+
+- Removed `server.ip_allowlist` and GitHub webhook `ip_allowlist` from `GET /api/v1/settings` responses and generated API clients
+- Removed server config support for `[server.ip_allowlist]` and `[server.integrations.github.webhooks.ip_allowlist]`
+
+
+
+- Fabro no longer performs runtime source-IP filtering; enforce inbound network restrictions upstream
+
diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs
index d7ddfbba2..3ef344399 100644
--- a/lib/crates/fabro-api/build.rs
+++ b/lib/crates/fabro-api/build.rs
@@ -244,21 +244,6 @@ fn main() {
"fabro_types::settings::server::ServerAuthGithubSettings",
&[],
),
- (
- "ServerIpAllowlistSettings",
- "fabro_types::settings::server::ServerIpAllowlistSettings",
- &[],
- ),
- (
- "ServerIpAllowlistOverrideSettings",
- "fabro_types::settings::server::ServerIpAllowlistOverrideSettings",
- &[],
- ),
- (
- "IpAllowEntry",
- "fabro_types::settings::server::IpAllowEntry",
- &[],
- ),
(
"ServerSandboxSettings",
"fabro_types::settings::server::ServerSandboxSettings",
diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs
index 6c0b06906..28903a67d 100644
--- a/lib/crates/fabro-api/src/lib.rs
+++ b/lib/crates/fabro-api/src/lib.rs
@@ -26,9 +26,8 @@ pub mod types {
pub use fabro_types::settings::ServerNamespace;
pub use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
- IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings,
- ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
- ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
+ LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
+ ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
diff --git a/lib/crates/fabro-api/tests/server_settings_round_trip.rs b/lib/crates/fabro-api/tests/server_settings_round_trip.rs
index a7421641b..d96002d8f 100644
--- a/lib/crates/fabro-api/tests/server_settings_round_trip.rs
+++ b/lib/crates/fabro-api/tests/server_settings_round_trip.rs
@@ -64,6 +64,9 @@ strategy = "app"
app_id = "12345"
client_id = "Iv1.abcdef"
slug = "fabro-dev"
+
+[server.integrations.github.webhooks]
+strategy = "tailscale_funnel"
"#,
)
.expect("settings should resolve");
@@ -85,6 +88,16 @@ slug = "fabro-dev"
json["server"]["sandbox"]["providers"]["daytona"]["enabled"],
false
);
+ assert!(
+ json["server"].get("ip_allowlist").is_none(),
+ "server settings API should not expose removed IP allowlist settings"
+ );
+ assert!(
+ json["server"]["integrations"]["github"]["webhooks"]
+ .get("ip_allowlist")
+ .is_none(),
+ "github webhook settings API should not expose removed IP allowlist settings"
+ );
assert!(json.get("features").is_none());
let round_trip: ApiServerSettings =
diff --git a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs
index e018ff988..cfaf06fd0 100644
--- a/lib/crates/fabro-cli/tests/it/support/auth_harness.rs
+++ b/lib/crates/fabro-cli/tests/it/support/auth_harness.rs
@@ -21,7 +21,6 @@ use chrono::{Duration as ChronoDuration, Utc};
use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget};
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_server::auth::GithubEndpoints;
-use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{RouterOptions, build_router_with_options};
use fabro_server::test_support::TestAppStateBuilder;
@@ -92,21 +91,15 @@ impl RealAuthHarness {
.vault_entries([("GITHUB_APP_CLIENT_SECRET", github_client_secret.as_str())])
.build();
let github_base = github_base_url(&twin.base_url);
- let router = build_router_with_options(
- state,
- &auth_mode,
- Arc::new(IpAllowlistConfig::default()),
- RouterOptions {
- web_enabled: true,
- github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
- github_base.clone(),
- github_base,
- ))),
- github_webhook_ip_allowlist: None,
- static_asset_root: None,
- watch_web: false,
- },
- );
+ let router = build_router_with_options(state, &auth_mode, RouterOptions {
+ web_enabled: true,
+ github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
+ github_base.clone(),
+ github_base,
+ ))),
+ static_asset_root: None,
+ watch_web: false,
+ });
let api_requests = ListenerRequestLog::default();
let api_server = RunningHttpServer::start(api_listener, router, &api_requests);
diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs
index 6c425c33d..b3aaac0ac 100644
--- a/lib/crates/fabro-config/src/layers/mod.rs
+++ b/lib/crates/fabro-config/src/layers/mod.rs
@@ -41,10 +41,10 @@ pub use run::{
pub use server::{
GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer,
- ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer,
- ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
- ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
- ServerWebLayer, SlackIntegrationLayer,
+ ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
+ ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
+ ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
+ SlackIntegrationLayer,
};
pub use settings::SettingsLayer;
pub use workflow::WorkflowLayer;
diff --git a/lib/crates/fabro-config/src/layers/server.rs b/lib/crates/fabro-config/src/layers/server.rs
index 62939a0e3..b68277d9e 100644
--- a/lib/crates/fabro-config/src/layers/server.rs
+++ b/lib/crates/fabro-config/src/layers/server.rs
@@ -21,8 +21,6 @@ pub struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub ip_allowlist: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option,
@@ -93,24 +91,6 @@ pub struct ServerAuthGithubLayer {
pub allowed_usernames: Vec,
}
-#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
-#[serde(deny_unknown_fields)]
-pub struct ServerIpAllowlistLayer {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub entries: Option>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub trusted_proxy_count: Option,
-}
-
-#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
-#[serde(deny_unknown_fields)]
-pub struct ServerIpAllowlistOverrideLayer {
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub entries: Option>,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub trusted_proxy_count: Option,
-}
-
/// `[server.sandbox]` — server-owned sandbox provider policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
#[serde(deny_unknown_fields)]
@@ -261,7 +241,5 @@ pub struct SlackIntegrationLayer {
#[serde(deny_unknown_fields)]
pub struct IntegrationWebhooksLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub strategy: Option,
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub ip_allowlist: Option,
+ pub strategy: Option,
}
diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs
index 9d53800b7..ccabacd0f 100644
--- a/lib/crates/fabro-config/src/lib.rs
+++ b/lib/crates/fabro-config/src/lib.rs
@@ -55,11 +55,10 @@ pub use layers::{
RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer,
RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer,
RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer,
- ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
- ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer,
- ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer,
- ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer,
- SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
+ ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer,
+ ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer,
+ ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer,
+ ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer,
};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;
diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs
index cfdf08c7a..9a2538295 100644
--- a/lib/crates/fabro-config/src/resolve/server.rs
+++ b/lib/crates/fabro-config/src/resolve/server.rs
@@ -1,9 +1,8 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
- IpAllowEntry, ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings,
- ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings,
- ServerIntegrationsSettings, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
+ ObjectStoreProvider, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings,
+ ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings,
ServerListenSettings, ServerLoggingSettings, ServerNamespace, ServerSandboxProviderSettings,
ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings,
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
@@ -15,9 +14,9 @@ use super::{ResolveError, default_interp, parse_socket_addr, require_interp};
use crate::user::default_storage_dir;
use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
- ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
- ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSandboxLayer,
- ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer,
+ ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer,
+ ServerSandboxLayer, ServerSandboxProviderLayer, ServerSlateDbLayer, ServerStorageLayer,
+ ServerWebLayer,
};
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> ServerNamespace {
@@ -25,10 +24,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se
let listen = resolve_listen(layer.listen.as_ref(), errors);
let web = resolve_web(layer.web.as_ref());
let auth = resolve_auth(layer.auth.as_ref(), errors);
- let ip_allowlist = resolve_ip_allowlist(layer.ip_allowlist.as_ref(), errors);
- let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
- validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors);
- validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors);
+ let integrations = resolve_integrations(layer.integrations.as_ref());
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
ServerNamespace {
@@ -38,7 +34,6 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se
},
web,
auth,
- ip_allowlist,
sandbox: resolve_sandbox(layer.sandbox.as_ref()),
storage: storage.clone(),
artifacts: resolve_artifacts(layer.artifacts.as_ref(), &storage.root, errors),
@@ -176,166 +171,6 @@ fn resolve_auth(
}
}
-fn resolve_ip_allowlist(
- layer: Option<&ServerIpAllowlistLayer>,
- errors: &mut Vec,
-) -> ServerIpAllowlistSettings {
- let entries = layer
- .and_then(|allowlist| allowlist.entries.as_ref())
- .map(|entries| {
- resolve_ip_allow_entries(entries, "server.ip_allowlist.entries", false, errors)
- })
- .unwrap_or_default();
-
- ServerIpAllowlistSettings {
- entries,
- trusted_proxy_count: layer
- .and_then(|allowlist| allowlist.trusted_proxy_count)
- .unwrap_or(0),
- }
-}
-
-fn effective_ip_allowlist_settings(
- global: &ServerIpAllowlistSettings,
- overlay: Option<&ServerIpAllowlistOverrideSettings>,
-) -> ServerIpAllowlistSettings {
- let Some(overlay) = overlay else {
- return global.clone();
- };
-
- ServerIpAllowlistSettings {
- entries: overlay
- .entries
- .clone()
- .unwrap_or_else(|| global.entries.clone()),
- trusted_proxy_count: overlay
- .trusted_proxy_count
- .unwrap_or(global.trusted_proxy_count),
- }
-}
-
-fn resolve_ip_allowlist_override(
- layer: Option<&ServerIpAllowlistOverrideLayer>,
- path: &str,
- allow_github_meta_hooks: bool,
- errors: &mut Vec,
-) -> Option {
- layer.map(|allowlist| ServerIpAllowlistOverrideSettings {
- entries: allowlist.entries.as_ref().map(|entries| {
- resolve_ip_allow_entries(
- entries,
- &format!("{path}.entries"),
- allow_github_meta_hooks,
- errors,
- )
- }),
- trusted_proxy_count: allowlist.trusted_proxy_count,
- })
-}
-
-fn resolve_ip_allow_entries(
- entries: &[String],
- path: &str,
- allow_github_meta_hooks: bool,
- errors: &mut Vec,
-) -> Vec {
- entries
- .iter()
- .enumerate()
- .filter_map(|(index, entry)| {
- resolve_ip_allow_entry(
- entry,
- &format!("{path}[{index}]"),
- allow_github_meta_hooks,
- errors,
- )
- })
- .collect()
-}
-
-fn resolve_ip_allow_entry(
- entry: &str,
- path: &str,
- allow_github_meta_hooks: bool,
- errors: &mut Vec,
-) -> Option {
- if entry == IpAllowEntry::GITHUB_META_HOOKS_KEYWORD {
- if allow_github_meta_hooks {
- return Some(IpAllowEntry::GitHubMetaHooks);
- }
-
- errors.push(ResolveError::Invalid {
- path: path.to_string(),
- reason: format!(
- "`{}` is only valid in server.integrations.github.webhooks.ip_allowlist.entries",
- IpAllowEntry::GITHUB_META_HOOKS_KEYWORD
- ),
- });
- return None;
- }
-
- match IpAllowEntry::parse_literal(entry) {
- Ok(parsed) => Some(parsed),
- Err(reason) => {
- errors.push(ResolveError::ParseFailure {
- path: path.to_string(),
- reason,
- });
- None
- }
- }
-}
-
-fn validate_ip_allowlist_for_listen(
- listen: &ServerListenSettings,
- ip_allowlist: &ServerIpAllowlistSettings,
- errors: &mut Vec,
-) {
- if matches!(listen, ServerListenSettings::Unix { .. })
- && !ip_allowlist.entries.is_empty()
- && ip_allowlist.trusted_proxy_count == 0
- {
- errors.push(ResolveError::Invalid {
- path: "server.ip_allowlist.trusted_proxy_count".to_string(),
- reason: "must be greater than 0 when using a Unix socket listener with a non-empty IP allowlist".to_string(),
- });
- }
-}
-
-fn validate_github_webhook_ip_allowlist_for_listen(
- listen: &ServerListenSettings,
- global_ip_allowlist: &ServerIpAllowlistSettings,
- integrations: &ServerIntegrationsSettings,
- errors: &mut Vec,
-) {
- let Some(overlay) = integrations
- .github
- .webhooks
- .as_ref()
- .and_then(|webhooks| webhooks.ip_allowlist.as_ref())
- else {
- return;
- };
-
- let effective = effective_ip_allowlist_settings(global_ip_allowlist, Some(overlay));
- if effective == *global_ip_allowlist {
- return;
- }
-
- if matches!(listen, ServerListenSettings::Unix { .. })
- && !effective.entries.is_empty()
- && effective.trusted_proxy_count == 0
- {
- errors.push(ResolveError::Invalid {
- path: "server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count"
- .to_string(),
- reason:
- "must be greater than 0 when using a Unix socket listener with a non-empty GitHub webhook IP allowlist"
- .to_string(),
- });
- }
-}
-
fn validate_github_webhook_strategy(
integrations: &ServerIntegrationsSettings,
api_layer: Option<&ServerApiLayer>,
@@ -476,10 +311,7 @@ fn object_store_default_root(storage_root: &InterpString, domain: &str) -> Inter
InterpString::parse(&format!("{root}/objects/{domain}"))
}
-fn resolve_integrations(
- layer: Option<&ServerIntegrationsLayer>,
- errors: &mut Vec,
-) -> ServerIntegrationsSettings {
+fn resolve_integrations(layer: Option<&ServerIntegrationsLayer>) -> ServerIntegrationsSettings {
ServerIntegrationsSettings {
github: layer
.and_then(|integrations| integrations.github.as_ref())
@@ -489,9 +321,7 @@ fn resolve_integrations(
app_id: github.app_id.clone(),
client_id: github.client_id.clone(),
slug: github.slug.clone(),
- webhooks: github.webhooks.as_ref().map(|webhooks| {
- resolve_github_webhooks(webhooks, "server.integrations.github.webhooks", errors)
- }),
+ webhooks: github.webhooks.as_ref().map(resolve_github_webhooks),
})
.unwrap_or_default(),
slack: layer
@@ -504,18 +334,8 @@ fn resolve_integrations(
}
}
-fn resolve_github_webhooks(
- layer: &IntegrationWebhooksLayer,
- path: &str,
- errors: &mut Vec,
-) -> IntegrationWebhooksSettings {
+fn resolve_github_webhooks(layer: &IntegrationWebhooksLayer) -> IntegrationWebhooksSettings {
IntegrationWebhooksSettings {
- strategy: layer.strategy,
- ip_allowlist: resolve_ip_allowlist_override(
- layer.ip_allowlist.as_ref(),
- &format!("{path}.ip_allowlist"),
- true,
- errors,
- ),
+ strategy: layer.strategy,
}
}
diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs
index 5a8d38c35..be646e73c 100644
--- a/lib/crates/fabro-config/src/tests/resolve_server.rs
+++ b/lib/crates/fabro-config/src/tests/resolve_server.rs
@@ -5,7 +5,7 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
- GithubIntegrationStrategy, IpAllowEntry, LogDestination, ObjectStoreSettings, ServerAuthMethod,
+ GithubIntegrationStrategy, LogDestination, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_util::Home;
@@ -427,92 +427,37 @@ disk_cache = true
}
#[test]
-fn resolves_empty_ip_allowlist_by_default() {
- let settings = resolve_server(&empty_settings_with_auth_methods());
-
- assert!(settings.ip_allowlist.entries.is_empty());
- assert_eq!(settings.ip_allowlist.trusted_proxy_count, 0);
-}
-
-#[test]
-fn resolves_global_ip_allowlist_entries_and_proxy_count() {
- let file = parse(
- r#"
-_version = 1
-
-[server.ip_allowlist]
-entries = ["10.0.0.0/8", "2001:db8::/32", "192.0.2.42"]
-trusted_proxy_count = 2
-"#,
- );
-
- let settings = resolve_server(&file);
-
- assert_eq!(settings.ip_allowlist.entries, vec![
- IpAllowEntry::parse_literal("10.0.0.0/8").unwrap(),
- IpAllowEntry::parse_literal("2001:db8::/32").unwrap(),
- IpAllowEntry::parse_literal("192.0.2.42").unwrap(),
- ]);
- assert_eq!(settings.ip_allowlist.trusted_proxy_count, 2);
-}
-
-#[test]
-fn resolves_github_webhook_ip_allowlist_overlay_with_inheritance() {
- let file = parse(
- r#"
+fn parsing_rejects_removed_server_ip_allowlist() {
+ let err = r#"
_version = 1
[server.ip_allowlist]
entries = ["10.0.0.0/8"]
-trusted_proxy_count = 2
+"#
+ .parse::()
+ .expect_err("removed server IP allowlist setting should be rejected");
+
+ assert!(
+ err.to_string().contains("ip_allowlist"),
+ "unexpected error: {err}"
+ );
+}
+
+#[test]
+fn parsing_rejects_removed_github_webhook_ip_allowlist() {
+ let err = r#"
+_version = 1
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
-"#,
+"#
+ .parse::()
+ .expect_err("removed github webhook IP allowlist setting should be rejected");
+
+ assert!(
+ err.to_string().contains("ip_allowlist"),
+ "unexpected error: {err}"
);
-
- let settings = resolve_server(&file);
- let webhook_allowlist = settings
- .integrations
- .github
- .webhooks
- .expect("github webhooks settings should resolve")
- .ip_allowlist
- .expect("github webhook ip allowlist overlay should resolve");
-
- assert_eq!(
- webhook_allowlist.entries,
- Some(vec![IpAllowEntry::GitHubMetaHooks])
- );
- assert_eq!(webhook_allowlist.trusted_proxy_count, None);
-}
-
-#[test]
-fn resolves_github_webhook_ip_allowlist_override_proxy_count() {
- let file = parse(
- r#"
-_version = 1
-
-[server.ip_allowlist]
-entries = ["10.0.0.0/8"]
-trusted_proxy_count = 2
-
-[server.integrations.github.webhooks.ip_allowlist]
-trusted_proxy_count = 3
-"#,
- );
-
- let settings = resolve_server(&file);
- let webhook_allowlist = settings
- .integrations
- .github
- .webhooks
- .expect("github webhooks settings should resolve")
- .ip_allowlist
- .expect("github webhook ip allowlist overlay should resolve");
-
- assert_eq!(webhook_allowlist.entries, None);
- assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3));
}
#[test]
@@ -558,91 +503,6 @@ strategy = "tailscale_funnel"
assert!(rendered.contains("server.integrations.github.app_id"));
}
-#[test]
-fn rejects_invalid_ip_allowlist_entry() {
- let file = parse(
- r#"
-_version = 1
-
-[server.ip_allowlist]
-entries = ["10.0.0.0/33"]
-"#,
- );
-
- let rendered = render_resolve_error_lines(
- ServerSettingsBuilder::from_layer(&file).expect_err("invalid CIDR should fail"),
- );
-
- assert!(rendered.contains("server.ip_allowlist.entries[0]"));
-}
-
-#[test]
-fn rejects_github_meta_hooks_in_global_scope() {
- let file = parse(
- r#"
-_version = 1
-
-[server.ip_allowlist]
-entries = ["github_meta_hooks"]
-"#,
- );
-
- let rendered = render_resolve_error_lines(
- ServerSettingsBuilder::from_layer(&file)
- .expect_err("github_meta_hooks should be rejected outside github webhooks"),
- );
-
- assert!(rendered.contains("server.ip_allowlist.entries[0]"));
-}
-
-#[test]
-fn rejects_unix_socket_allowlist_without_trusted_proxy() {
- let file = parse(
- r#"
-_version = 1
-
-[server.listen]
-type = "unix"
-path = "/tmp/fabro.sock"
-
-[server.ip_allowlist]
-entries = ["10.0.0.0/8"]
-"#,
- );
-
- let rendered = render_resolve_error_lines(
- ServerSettingsBuilder::from_layer(&file)
- .expect_err("unix allowlist without trusted proxies should fail"),
- );
-
- assert!(rendered.contains("server.ip_allowlist.trusted_proxy_count"));
-}
-
-#[test]
-fn rejects_unix_socket_github_webhook_allowlist_without_trusted_proxy() {
- let file = parse(
- r#"
-_version = 1
-
-[server.listen]
-type = "unix"
-path = "/tmp/fabro.sock"
-
-[server.integrations.github.webhooks.ip_allowlist]
-entries = ["github_meta_hooks"]
-"#,
- );
-
- let rendered = render_resolve_error_lines(
- ServerSettingsBuilder::from_layer(&file)
- .expect_err("unix github webhook allowlist without trusted proxies should fail"),
- );
-
- assert!(
- rendered.contains("server.integrations.github.webhooks.ip_allowlist.trusted_proxy_count")
- );
-}
-
#[test]
fn resolve_storage_root_defaults_with_minimal_server_auth_methods() {
let settings = ServerSettingsBuilder::from_layer(&empty_settings_with_auth_methods())
diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml
index 879cea7ce..dfde73f66 100644
--- a/lib/crates/fabro-server/Cargo.toml
+++ b/lib/crates/fabro-server/Cargo.toml
@@ -93,7 +93,6 @@ semver.workspace = true
walkdir.workspace = true
multer = "3"
thiserror.workspace = true
-ipnet = "2.11.0"
percent-encoding.workspace = true
url = "2"
zeroize.workspace = true
diff --git a/lib/crates/fabro-server/src/auth/translate.rs b/lib/crates/fabro-server/src/auth/translate.rs
index b1b8d8292..8d0f57cf5 100644
--- a/lib/crates/fabro-server/src/auth/translate.rs
+++ b/lib/crates/fabro-server/src/auth/translate.rs
@@ -546,12 +546,7 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_rejects_demo_cookie_without_credentials() {
let state = test_state();
- let app = server::build_router_with_options(
- state,
- &auth_mode(),
- Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
- RouterOptions::default(),
- );
+ let app = server::build_router_with_options(state, &auth_mode(), RouterOptions::default());
let response = app
.oneshot(
@@ -575,7 +570,6 @@ methods = ["dev-token"]
let app = server::build_router_with_options(
Arc::clone(&state),
&auth_mode(),
- Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
RouterOptions::default(),
);
@@ -601,12 +595,7 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_does_not_demo_dispatch_auth_routes() {
let state = test_state();
- let app = server::build_router_with_options(
- state,
- &auth_mode(),
- Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
- RouterOptions::default(),
- );
+ let app = server::build_router_with_options(state, &auth_mode(), RouterOptions::default());
let response = app
.oneshot(
@@ -630,18 +619,12 @@ methods = ["dev-token"]
#[tokio::test]
async fn full_router_accepts_dev_token_bearer_when_web_is_disabled() {
let state = test_state();
- let app = server::build_router_with_options(
- state,
- &auth_mode(),
- Arc::new(crate::ip_allowlist::IpAllowlistConfig::default()),
- RouterOptions {
- web_enabled: false,
- github_endpoints: None,
- github_webhook_ip_allowlist: None,
- static_asset_root: None,
- watch_web: false,
- },
- );
+ let app = server::build_router_with_options(state, &auth_mode(), RouterOptions {
+ web_enabled: false,
+ github_endpoints: None,
+ static_asset_root: None,
+ watch_web: false,
+ });
let response = app
.oneshot(
diff --git a/lib/crates/fabro-server/src/ip_allowlist.rs b/lib/crates/fabro-server/src/ip_allowlist.rs
deleted file mode 100644
index 2fe3fc6ec..000000000
--- a/lib/crates/fabro-server/src/ip_allowlist.rs
+++ /dev/null
@@ -1,609 +0,0 @@
-use std::net::{IpAddr, SocketAddr};
-use std::path::{Path, PathBuf};
-use std::sync::Arc;
-
-use anyhow::{Context, Result, anyhow};
-use axum::extract::{ConnectInfo, Request, State};
-use axum::http::Request as HttpRequest;
-use axum::middleware::Next;
-use axum::response::{IntoResponse, Response};
-use fabro_types::settings::server::{
- IpAllowEntry, ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings,
-};
-use ipnet::IpNet;
-use serde::{Deserialize, Serialize};
-use tokio::fs;
-use tracing::warn;
-
-use crate::ApiError;
-
-const GITHUB_META_URL: &str = "https://api.github.com/meta";
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct IpAllowlist {
- entries: Vec,
-}
-
-impl IpAllowlist {
- pub fn new(entries: Vec) -> Self {
- Self { entries }
- }
-
- pub fn is_empty(&self) -> bool {
- self.entries.is_empty()
- }
-
- pub fn contains(&self, ip: &IpAddr) -> bool {
- let ip = normalize_ip(*ip);
- self.entries.iter().any(|entry| entry.contains(&ip))
- }
-}
-
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct IpAllowlistConfig {
- pub allowlist: IpAllowlist,
- pub trusted_proxy_count: u32,
-}
-
-#[derive(Clone)]
-pub struct GitHubMetaResolver {
- client: HttpClient,
- meta_url: String,
- cache_path: PathBuf,
-}
-
-impl GitHubMetaResolver {
- pub fn new(client: HttpClient, meta_url: String, cache_path: PathBuf) -> Self {
- Self {
- client,
- meta_url,
- cache_path,
- }
- }
-
- pub fn from_cache_dir(cache_dir: &Path) -> Result {
- Ok(Self::new(
- fabro_http::http_client().context("building GitHub meta HTTP client")?,
- GITHUB_META_URL.to_string(),
- github_meta_cache_path(cache_dir),
- ))
- }
-
- async fn resolve_hooks(&self) -> Result> {
- let cached = self.load_cache().await?;
- let mut request = self
- .client
- .get(&self.meta_url)
- .header("Accept", "application/vnd.github+json")
- .header("User-Agent", "fabro");
-
- if let Some(etag) = cached.as_ref().and_then(|cache| cache.etag.as_deref()) {
- request = request.header("If-None-Match", etag);
- }
-
- let response = match request.send().await {
- Ok(response) => response,
- Err(error) => {
- return self.cached_hooks_or_error(
- cached.as_ref(),
- anyhow::Error::new(error).context("fetching GitHub /meta"),
- );
- }
- };
- if response.status() == fabro_http::StatusCode::NOT_MODIFIED {
- let cached = cached.ok_or_else(|| {
- anyhow!("GitHub /meta returned 304 Not Modified but no usable cache was present")
- })?;
- return parse_ip_nets(&cached.hooks);
- }
-
- if !response.status().is_success() {
- return self.cached_hooks_or_error(
- cached.as_ref(),
- anyhow!("GitHub /meta returned {}", response.status()),
- );
- }
-
- let etag = response
- .headers()
- .get("etag")
- .and_then(|value| value.to_str().ok())
- .map(ToOwned::to_owned);
- let payload: GitHubMetaResponse = match response.json().await {
- Ok(payload) => payload,
- Err(error) => {
- return self.cached_hooks_or_error(
- cached.as_ref(),
- anyhow::Error::new(error).context("parsing GitHub /meta response"),
- );
- }
- };
- let hooks = match parse_ip_nets(&payload.hooks) {
- Ok(hooks) => hooks,
- Err(error) => return self.cached_hooks_or_error(cached.as_ref(), error),
- };
- self.store_cache(&GitHubMetaCache {
- etag,
- hooks: payload.hooks,
- })
- .await?;
- Ok(hooks)
- }
-
- async fn load_cache(&self) -> Result