Files
windmill/backend/windmill-api/src/public_app_layer.rs
T
centdix 5c08abe141 feat(aiagent): handle oauth for mcp tools (#7564)
* feat(aiagent): handle oauth for mcp tools

* cleaning

* cleaning

* move oauth2

* cleaning

* cimd

* wrap path

* fix frontend

* fix

* refresh resources

* cleaning

* update ee ref

* update sqlx

* cleaning

* update

* cleaning

* update ref

* cleaning

* cleaning

* update ref
2026-01-14 12:06:40 +00:00

233 lines
7.0 KiB
Rust

/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use axum::body::Body;
use axum::response::Response;
use http::StatusCode;
lazy_static::lazy_static! {
pub static ref PUBLIC_APP_DOMAIN: Option<String> = std::env::var("PUBLIC_APP_DOMAIN").ok();
}
/// Middleware to restrict public app domain to whitelisted routes
pub async fn public_app_domain_filter(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
if let Some(public_domain) = PUBLIC_APP_DOMAIN.as_ref() {
if let Some(host) = req.headers().get(http::header::HOST) {
if let Ok(host_str) = host.to_str() {
// Extract hostname without port
let hostname = host_str.split(':').next().unwrap_or(host_str);
if hostname == public_domain {
let path = req.uri().path();
// Check if route is whitelisted
let is_whitelisted = is_public_route_whitelisted(path);
if !is_whitelisted {
tracing::warn!(
"Rejected request to {} on public app domain {}",
path,
public_domain
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Body::from(
"Access forbidden: route not allowed on public app domain",
))
.unwrap();
}
}
}
}
}
next.run(req).await
}
fn is_public_route_whitelisted(path: &str) -> bool {
// Whitelisted route patterns for public app domain
let whitelist = [
// Public app routes
"/api/ee_license",
"/api/w/*/users/whoami",
"/api/w/*/apps_u/*",
"/api/w/*/resources/list",
"/api/w/*/jobs_u/getupdate_sse/*",
"/api/auth/login",
"/api/w/*/folders/listnames",
"/api/w/*/resources/exists/*",
"/api/w/*/resources/type/get/*",
"/api/w/*/resources/type/listnames",
"/api/oauth/login/*",
"/api/oauth/connect/*",
"/oauth/callback/*",
"/api/mcp/oauth/callback",
"/user/login_callback/*",
"/api/workspaces/users",
"/api/users/whoami",
"/api/apps_u/*",
"/api/oauth/list_connects",
"/api/oauth/list_logins",
"/public/*",
"/a/*",
"/api/oauth/get_connect/*",
"/Inter-Variable.woff2",
];
for pattern in &whitelist {
if matches_pattern(path, pattern) {
return true;
}
}
false
}
fn matches_pattern(path: &str, pattern: &str) -> bool {
// Handle exact match
if path == pattern {
return true;
}
// Handle wildcard patterns
if !pattern.contains('*') {
return false;
}
let parts: Vec<&str> = pattern.split('*').collect();
// Start from the beginning - path must start with first part
if !path.starts_with(parts[0]) {
return false;
}
let mut remaining = &path[parts[0].len()..];
// Check each part in between wildcards
for i in 1..parts.len() - 1 {
let part = parts[i];
if let Some(pos) = remaining.find(part) {
remaining = &remaining[pos + part.len()..];
} else {
return false;
}
}
// Check the last part
let last = parts[parts.len() - 1];
if last.is_empty() {
// Wildcard at the end, already matched
return true;
} else {
// Must end with the last part
return remaining.ends_with(last);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_matches_pattern_exact() {
assert!(matches_pattern("/api/version", "/api/version"));
assert!(!matches_pattern("/api/versions", "/api/version"));
}
#[test]
fn test_matches_pattern_wildcard_middle() {
assert!(matches_pattern(
"/api/w/my-workspace/apps_u/my-app",
"/api/w/*/apps_u/*"
));
assert!(matches_pattern(
"/api/w/workspace123/jobs_u/job456",
"/api/w/*/jobs_u/*"
));
assert!(!matches_pattern(
"/api/w/workspace/apps/my-app",
"/api/w/*/apps_u/*"
));
}
#[test]
fn test_matches_pattern_wildcard_end() {
assert!(matches_pattern("/api/r/some/path", "/api/r/*"));
assert!(matches_pattern("/api/auth/login", "/api/auth/*"));
assert!(matches_pattern("/public/asset.css", "/public/*"));
assert!(!matches_pattern("/api/admin/users", "/api/r/*"));
}
#[test]
fn test_is_public_route_whitelisted() {
// Whitelisted routes based on current whitelist
assert!(is_public_route_whitelisted("/api/ee_license"));
assert!(is_public_route_whitelisted(
"/api/w/my-workspace/users/whoami"
));
assert!(is_public_route_whitelisted(
"/api/w/my-workspace/apps_u/my-app"
));
assert!(is_public_route_whitelisted(
"/api/w/workspace/resources/list"
));
// Non-whitelisted routes
assert!(!is_public_route_whitelisted("/api/version"));
assert!(!is_public_route_whitelisted("/api/uptodate"));
assert!(!is_public_route_whitelisted("/api/w/workspace/scripts"));
assert!(!is_public_route_whitelisted("/api/w/workspace/flows"));
assert!(!is_public_route_whitelisted(
"/api/w/workspace/apps/private"
));
assert!(!is_public_route_whitelisted("/api/admin/settings"));
assert!(!is_public_route_whitelisted("/api/users"));
}
#[test]
fn test_matches_pattern_complex_multiple_wildcards() {
// Test pattern with two wildcards like "/api/w/*/foo/*"
assert!(matches_pattern(
"/api/w/workspace1/s3_proxy/file.txt",
"/api/w/*/s3_proxy/*"
));
assert!(matches_pattern(
"/api/w/my-workspace/s3_proxy/path/to/file.jpg",
"/api/w/*/s3_proxy/*"
));
assert!(matches_pattern(
"/api/w/test/apps_u/myapp",
"/api/w/*/apps_u/*"
));
// Should not match if middle part is different
assert!(!matches_pattern(
"/api/w/workspace1/other/file.txt",
"/api/w/*/s3_proxy/*"
));
assert!(!matches_pattern(
"/api/w/workspace/apps/myapp",
"/api/w/*/apps_u/*"
));
// Edge cases
assert!(matches_pattern(
"/api/w/ws/s3_proxy/a",
"/api/w/*/s3_proxy/*"
));
// Path must have content after the last wildcard segment
assert!(matches_pattern(
"/api/w/ws/s3_proxy/",
"/api/w/*/s3_proxy/*"
));
}
}