diff --git a/fastapps/cli/commands/allow_csp.py b/fastapps/cli/commands/allow_csp.py new file mode 100644 index 0000000..c72d00a --- /dev/null +++ b/fastapps/cli/commands/allow_csp.py @@ -0,0 +1,197 @@ +"""CSP management command for FastApps projects.""" + +import json +import sys +from pathlib import Path +from typing import Literal + +from rich.console import Console +from rich.prompt import Prompt +from rich.table import Table + +console = Console() + + +def load_csp_config(project_root: Path) -> dict: + """Load CSP configuration from fastapps.json.""" + config_file = project_root / "fastapps.json" + + if config_file.exists(): + try: + with open(config_file, "r") as f: + return json.load(f) + except json.JSONDecodeError: + console.print("[yellow]Warning: Could not parse fastapps.json[/yellow]") + return {} + + return {} + + +def save_csp_config(project_root: Path, config: dict): + """Save CSP configuration to fastapps.json.""" + config_file = project_root / "fastapps.json" + + with open(config_file, "w") as f: + json.dump(config, f, indent=2) + + console.print(f"[green]✓ Saved to {config_file}[/green]") + + +def add_csp_domain( + url: str = None, + domain_type: Literal["resource", "connect"] = None, +): + """ + Add a domain to CSP allowlist. + + Args: + url: Domain URL to allow (e.g., https://example.com) + domain_type: Type of domain - "resource" for assets, "connect" for APIs + """ + # Check if we're in a FastApps project + project_root = Path.cwd() + if not (project_root / "server" / "main.py").exists(): + console.print("[red]Error: Not in a FastApps project directory[/red]") + console.print( + "[yellow]Run this command from your project root (where server/main.py exists)[/yellow]" + ) + return False + + # Interactive mode if arguments not provided + if url is None: + console.print("\n[cyan]Add CSP Domain[/cyan]\n") + url = Prompt.ask( + "[bold]Enter domain URL[/bold]", + default="https://example.com" + ) + + if domain_type is None: + console.print("\n[dim]Domain types:[/dim]") + console.print(" [cyan]resource[/cyan] - For scripts, styles, images, fonts") + console.print(" [cyan]connect[/cyan] - For API calls (fetch, XHR)\n") + + domain_type = Prompt.ask( + "[bold]Domain type[/bold]", + choices=["resource", "connect"], + default="resource" + ) + + # Validate URL + if not url.startswith("https://") and not url.startswith("http://"): + console.print("[red]Error: URL must start with https:// or http://[/red]") + return False + + # Load existing config + config = load_csp_config(project_root) + + # Initialize CSP section if not exists + if "csp" not in config: + config["csp"] = { + "resource_domains": [], + "connect_domains": [] + } + + # Add domain to appropriate list + domain_key = f"{domain_type}_domains" + if domain_key not in config["csp"]: + config["csp"][domain_key] = [] + + if url in config["csp"][domain_key]: + console.print(f"[yellow]Domain already exists in {domain_type}_domains[/yellow]") + return True + + config["csp"][domain_key].append(url) + + # Save config + save_csp_config(project_root, config) + + # Show updated config + console.print(f"\n[green]✓ Added {url} to {domain_type}_domains[/green]\n") + show_csp_config(project_root) + + return True + + +def remove_csp_domain( + url: str = None, + domain_type: Literal["resource", "connect"] = None, +): + """Remove a domain from CSP allowlist.""" + project_root = Path.cwd() + if not (project_root / "server" / "main.py").exists(): + console.print("[red]Error: Not in a FastApps project directory[/red]") + return False + + # Load existing config + config = load_csp_config(project_root) + + if "csp" not in config: + console.print("[yellow]No CSP configuration found[/yellow]") + return False + + # Interactive mode if arguments not provided + if url is None or domain_type is None: + show_csp_config(project_root) + console.print() + + if url is None: + url = Prompt.ask("[bold]Enter domain URL to remove[/bold]") + + if domain_type is None: + domain_type = Prompt.ask( + "[bold]Domain type[/bold]", + choices=["resource", "connect"] + ) + + # Remove domain + domain_key = f"{domain_type}_domains" + if domain_key in config["csp"] and url in config["csp"][domain_key]: + config["csp"][domain_key].remove(url) + save_csp_config(project_root, config) + console.print(f"\n[green]✓ Removed {url} from {domain_type}_domains[/green]\n") + show_csp_config(project_root) + return True + else: + console.print(f"[yellow]Domain not found in {domain_type}_domains[/yellow]") + return False + + +def show_csp_config(project_root: Path = None): + """Display current CSP configuration.""" + if project_root is None: + project_root = Path.cwd() + + config = load_csp_config(project_root) + + if "csp" not in config or ( + not config["csp"].get("resource_domains") and + not config["csp"].get("connect_domains") + ): + console.print("[yellow]No CSP domains configured[/yellow]") + return + + table = Table(title="CSP Configuration", title_style="bold cyan") + table.add_column("Type", style="cyan", no_wrap=True) + table.add_column("Domain", style="white") + + for domain in config["csp"].get("resource_domains", []): + table.add_row("resource", domain) + + for domain in config["csp"].get("connect_domains", []): + table.add_row("connect", domain) + + console.print(table) + + +def list_csp_domains(): + """List all configured CSP domains.""" + project_root = Path.cwd() + if not (project_root / "server" / "main.py").exists(): + console.print("[red]Error: Not in a FastApps project directory[/red]") + return False + + console.print() + show_csp_config(project_root) + console.print() + + return True diff --git a/fastapps/cli/commands/init.py b/fastapps/cli/commands/init.py index 3263258..3d4942c 100644 --- a/fastapps/cli/commands/init.py +++ b/fastapps/cli/commands/init.py @@ -93,8 +93,29 @@ def auto_load_tools(build_results): # Auto-load and register tools tools = auto_load_tools(build_results) -# Create MCP server -server = WidgetMCPServer(name="my-widgets", widgets=tools) +# Load CSP configuration from fastapps.json +def load_csp_config(): + \"\"\"Load CSP configuration from fastapps.json if exists.\"\"\" + config_file = PROJECT_ROOT / "fastapps.json" + if config_file.exists(): + try: + import json + with open(config_file, "r") as f: + config = json.load(f) + return config.get("csp", {}) + except Exception as e: + print(f"[WARNING] Could not load CSP config: {e}") + return {} + +csp_config = load_csp_config() + +# Create MCP server with CSP configuration +server = WidgetMCPServer( + name="my-widgets", + widgets=tools, + global_resource_domains=csp_config.get("resource_domains", []), + global_connect_domains=csp_config.get("connect_domains", []), +) # Optional: Enable OAuth 2.1 authentication # Uncomment and configure to protect your widgets with OAuth: @@ -188,6 +209,26 @@ def get_package_json(project_name: str) -> str: └── package.json ``` +## Content Security Policy (CSP) + +Your project includes a default CSP configuration in `fastapps.json` that allows loading images from a safe public CDN. You can manage CSP domains using the CLI: + +```bash +# Add external resource domains (images, fonts, styles) +fastapps csp add --url https://your-cdn.com --type resource + +# Add API domains (fetch, XHR) +fastapps csp add --url https://api.example.com --type connect + +# List all configured domains +fastapps csp list + +# Remove a domain +fastapps csp remove --url https://example.com --type resource +``` + +The default domain (`https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev`) is a safe public CDN used by example widgets. You can remove it if not needed. + ## Learn More - **FastApps Framework**: https://pypi.org/project/fastapps/ @@ -292,6 +333,21 @@ def init_project(project_name: str): console.print("Creating .gitignore...") (project_path / ".gitignore").write_text(GITIGNORE) + # Create fastapps.json with default CSP + console.print("Creating fastapps.json...") + import json + fastapps_config = { + "csp": { + "resource_domains": [ + "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev" + ], + "connect_domains": [] + } + } + (project_path / "fastapps.json").write_text( + json.dumps(fastapps_config, indent=2) + ) + console.print( f"\n[green][OK] Project '{project_name}' created successfully![/green]" ) diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index 2dfbb06..e835e17 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -5,6 +5,7 @@ from fastapps.core.utils import get_cli_version +from .commands.allow_csp import add_csp_domain, list_csp_domains, remove_csp_domain from .commands.build import build_command from .commands.cloud import cloud from .commands.create import create_widget @@ -201,5 +202,70 @@ def use(integration_name): use_integration(integration_name) +# CSP management command group +@cli.group() +def csp(): + """Manage Content Security Policy (CSP) for widgets. + + Configure domains allowed to load external resources (scripts, styles, images) + and make API calls (fetch, XHR) from your widgets. + + All CSP configuration is stored in fastapps.json and automatically + loaded when creating WidgetMCPServer. + """ + pass + + +@csp.command("add") +@click.option("--url", help="Domain URL to allow (e.g., https://example.com)") +@click.option( + "--type", + "domain_type", + type=click.Choice(["resource", "connect"], case_sensitive=False), + help="Domain type: 'resource' for assets, 'connect' for APIs", +) +def csp_add(url, domain_type): + """Add a domain to CSP allowlist. + + Examples: + fastapps csp add --url https://pub-YOUR-ID.r2.dev --type resource + fastapps csp add --url https://api.example.com --type connect + fastapps csp add # Interactive mode + + Domain types: + resource - For scripts, styles, images, fonts (e.g., CDN, R2 buckets) + connect - For API calls via fetch/XHR (e.g., external APIs) + """ + add_csp_domain(url=url, domain_type=domain_type) + + +@csp.command("remove") +@click.option("--url", help="Domain URL to remove") +@click.option( + "--type", + "domain_type", + type=click.Choice(["resource", "connect"], case_sensitive=False), + help="Domain type: 'resource' or 'connect'", +) +def csp_remove(url, domain_type): + """Remove a domain from CSP allowlist. + + Examples: + fastapps csp remove --url https://example.com --type resource + fastapps csp remove # Interactive mode + """ + remove_csp_domain(url=url, domain_type=domain_type) + + +@csp.command("list") +def csp_list(): + """List all configured CSP domains. + + Shows currently configured resource and connect domains + from fastapps.json. + """ + list_csp_domains() + + if __name__ == "__main__": cli() diff --git a/fastapps/core/server.py b/fastapps/core/server.py index e4f74c3..f089ef6 100644 --- a/fastapps/core/server.py +++ b/fastapps/core/server.py @@ -38,9 +38,12 @@ def __init__( auth_required_scopes: Optional[List[str]] = None, auth_audience: Optional[str] = None, token_verifier: Optional["TokenVerifier"] = None, + # Global CSP configuration for all widgets (optional) + global_resource_domains: Optional[List[str]] = None, + global_connect_domains: Optional[List[str]] = None, ): """ - Initialize MCP server with optional OAuth authentication. + Initialize MCP server with optional OAuth authentication and global CSP. Args: name: Server name @@ -50,6 +53,8 @@ def __init__( auth_required_scopes: Required OAuth scopes (e.g., ["user", "read:data"]) auth_audience: JWT audience claim (optional) token_verifier: Custom TokenVerifier (optional, uses JWTVerifier if not provided) + global_resource_domains: Domains to allow for all widgets (scripts, styles, images) + global_connect_domains: Domains to allow for API calls (fetch, XHR) Example (Simple): server = WidgetMCPServer( @@ -60,6 +65,19 @@ def __init__( auth_required_scopes=["user"], ) + Example (With Global CSP): + server = WidgetMCPServer( + name="my-widgets", + widgets=tools, + global_resource_domains=[ + "https://pub-YOUR-BUCKET-ID.r2.dev", # R2 images + "https://fonts.googleapis.com", # Google Fonts + ], + global_connect_domains=[ + "https://api.example.com", # External API + ], + ) + Example (Custom Verifier): server = WidgetMCPServer( name="my-widgets", @@ -73,7 +91,11 @@ def __init__( self.widgets_by_uri = {w.template_uri: w for w in widgets} self.client_locale: Optional[str] = None - # Auto-configure widget CSP based on PUBLIC_URL environment variable + # Store global CSP configuration + self.global_resource_domains = global_resource_domains or [] + self.global_connect_domains = global_connect_domains or [] + + # Auto-configure widget CSP based on PUBLIC_URL and global settings self._configure_widget_csp(widgets) # Store server auth configuration for per-widget inheritance @@ -126,28 +148,47 @@ def __init__( self._register_handlers() def _configure_widget_csp(self, widgets: List[BaseWidget]): - """Auto-configure widget CSP based on PUBLIC_URL environment variable.""" + """ + Auto-configure widget CSP based on: + 1. PUBLIC_URL environment variable + 2. Global CSP domains (global_resource_domains, global_connect_domains) + 3. Widget-specific CSP (widget.widget_csp) + """ import os public_url = os.environ.get("PUBLIC_URL", "").strip() - if not public_url: - return - - # Configure CSP for all widgets that don't have custom CSP + # Configure CSP for all widgets for widget in widgets: - # Check if CSP is not configured (None or empty resource_domains) - needs_csp = ( - widget.widget_csp is None or - not widget.widget_csp.get("resource_domains") - ) - - if needs_csp: + # Initialize CSP if not present + if widget.widget_csp is None: widget.widget_csp = { - "resource_domains": [public_url], + "resource_domains": [], "connect_domains": [] } + # Get existing domains + resource_domains = widget.widget_csp.get("resource_domains", []) + connect_domains = widget.widget_csp.get("connect_domains", []) + + # Merge PUBLIC_URL + if public_url and public_url not in resource_domains: + resource_domains.append(public_url) + + # Merge global resource domains + for domain in self.global_resource_domains: + if domain not in resource_domains: + resource_domains.append(domain) + + # Merge global connect domains + for domain in self.global_connect_domains: + if domain not in connect_domains: + connect_domains.append(domain) + + # Update widget CSP + widget.widget_csp["resource_domains"] = resource_domains + widget.widget_csp["connect_domains"] = connect_domains + def _register_handlers(self): """Register all MCP handlers for widget support.""" server = self.mcp._mcp_server diff --git a/fastapps/templates/carousel/tool.py b/fastapps/templates/carousel/tool.py index 71030d4..490026c 100644 --- a/fastapps/templates/carousel/tool.py +++ b/fastapps/templates/carousel/tool.py @@ -20,6 +20,13 @@ class {ClassName}Tool(BaseWidget): invoked = "Carousel ready!" input_schema = {ClassName}Input + # Optional: Configure widget-specific CSP (if needed) + # For project-wide domains, use global_resource_domains in WidgetMCPServer instead + # widget_csp = { + # "resource_domains": ["https://example.com"], + # "connect_domains": [] + # } + async def execute(self, input_data: {ClassName}Input, context=None, user=None): # Example: Return sample cards for carousel return {