# Milestone 6: Edge Functions **Goal:** `supabase.functions.invoke('my-function')` executes user code safely with proper isolation. **Depends on:** M0 (Security), M1 (Foundation) --- ## 6.1 — Security Fixes ### 6.1.1 Sandbox the Deno runtime **File:** `functions/src/deno_runtime.rs` line 46 **Current problem:** `FsModuleLoader` allows user functions to `import` any file from the server filesystem, including `/etc/passwd`, source code, `.env` files, etc. **Fix:** Create a restricted module loader: ```rust use deno_core::{ModuleLoader, ModuleSource, ModuleSourceCode, ModuleType, ModuleLoadResponse, RequestedModuleType}; struct SandboxedModuleLoader { allowed_dir: PathBuf, } impl ModuleLoader for SandboxedModuleLoader { fn resolve(&self, specifier: &str, referrer: &str, _kind: deno_core::ResolutionKind) -> Result { let resolved = deno_core::resolve_import(specifier, referrer)?; // Only allow file:// URLs within the allowed directory if resolved.scheme() == "file" { let path = resolved.to_file_path() .map_err(|_| anyhow::anyhow!("Invalid file path"))?; let canonical = path.canonicalize() .map_err(|_| anyhow::anyhow!("Path not found: {}", path.display()))?; if !canonical.starts_with(&self.allowed_dir) { return Err(anyhow::anyhow!( "Import blocked: {} is outside the allowed directory", specifier )); } } // Allow https:// imports (for deno.land, esm.sh, etc.) // Block other schemes if resolved.scheme() != "file" && resolved.scheme() != "https" { return Err(anyhow::anyhow!("Blocked import scheme: {}", resolved.scheme())); } Ok(resolved) } fn load(&self, specifier: &deno_core::ModuleSpecifier, _maybe_referrer: Option<&deno_core::ModuleSpecifier>, _is_dynamic: bool) -> ModuleLoadResponse { // For file:// — read from disk (already validated in resolve) // For https:// — fetch (or block if network disabled) // Default implementation delegates to FsModuleLoader for files todo!("implement based on scheme") } } ``` Use in runtime creation: ```rust let temp_dir = PathBuf::from(format!("/tmp/madbase_functions/{}", function_name)); let runtime = JsRuntime::new(deno_core::RuntimeOptions { module_loader: Some(Rc::new(SandboxedModuleLoader { allowed_dir: temp_dir })), ..Default::default() }); ``` ### 6.1.2 Pass data safely (fix JS injection) **File:** `functions/src/deno_runtime.rs` lines 122-156 **Current (vulnerable):** ```rust let module_code = format!(r#" const req = new Request("http://localhost", {{ method: "POST", body: {payload_json}, // INJECTION POINT headers: {headers_json} // INJECTION POINT }}); "#); ``` **Fix — double-serialize to create safe JS string literals:** ```rust // Serialize payload/headers to JSON strings, then JSON-encode THOSE strings // so they become valid JS string literals let payload_str = serde_json::to_string(&payload_json)?; // JSON string let headers_str = serde_json::to_string(&headers_json)?; let safe_payload = serde_json::to_string(&payload_str)?; // "\"escaped JSON\"" let safe_headers = serde_json::to_string(&headers_str)?; let module_code = format!(r#" const req = new Request("http://localhost", {{ method: "POST", body: JSON.parse({safe_payload}), headers: JSON.parse({safe_headers}) }}); "#); ``` This guarantees the interpolated values are valid JSON string literals that cannot break out of the JS context. ### 6.1.3 Resource limits Add execution limits: ```rust // Timeout (already exists at 30s, keep it) tokio::time::timeout(Duration::from_secs(30), rx).await // Memory limit — use V8's heap limit let mut runtime = JsRuntime::new(deno_core::RuntimeOptions { create_params: Some( v8::CreateParams::default() .heap_limits(0, 128 * 1024 * 1024) // 128MB max heap ), ..Default::default() }); // Register a near-heap-limit callback to terminate let isolate = runtime.v8_isolate(); isolate.add_near_heap_limit_callback(|current, initial| { // Terminate the isolate current // Don't increase the limit }); ``` --- ## 6.2 — Developer Experience ### 6.2.1 TypeScript support Deno natively compiles TypeScript. The current setup writes `.js` files — change to `.ts`: ```rust let temp_path = format!("/tmp/deno_main_{}.ts", uuid::Uuid::new_v4()); ``` Deno will transparently compile TypeScript to JavaScript. ### 6.2.2 Support fetch() The current preamble defines custom `Request`/`Response` classes but doesn't provide `fetch()`. Deno's built-in `fetch` requires network permissions. Since we're using `deno_core` (not the full Deno CLI), we need to either: 1. Add `deno_fetch` extension to the runtime 2. Or implement a minimal `fetch` via `Deno.core.ops` Option 1 (recommended): ```rust // Add deno_fetch to extensions let mut runtime = JsRuntime::new(deno_core::RuntimeOptions { extensions: vec![deno_fetch::deno_fetch::init_ops::(Default::default())], ..Default::default() }); ``` This requires adding `deno_fetch` as a dependency and implementing a `Permissions` struct that controls which URLs can be accessed. ### 6.2.3 Environment variables Pass project-level env vars to the function context: ```rust // Before executing user code let env_vars = get_project_env_vars(&db, &project_ref).await?; let env_json = serde_json::to_string(&env_vars)?; runtime.execute_script("", format!("globalThis._env = JSON.parse('{}');", env_json))?; ``` This makes `Deno.env.get("MY_VAR")` work (already polyfilled in the preamble). ### 6.2.4 Worker pooling **Current problem:** Each invocation spawns a new OS thread + tokio runtime. This has ~10ms overhead per invocation and wastes memory. **Fix:** Pre-warm a pool of worker threads: ```rust use tokio::sync::mpsc; pub struct DenoPool { sender: mpsc::Sender, } struct DenoTask { code: String, payload: Option, headers: HashMap, response: oneshot::Sender)>>, } impl DenoPool { pub fn new(pool_size: usize) -> Self { let (tx, rx) = mpsc::channel(pool_size * 2); let rx = Arc::new(Mutex::new(rx)); for _ in 0..pool_size { let rx = rx.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all().build().unwrap(); let local = tokio::task::LocalSet::new(); local.block_on(&rt, async { loop { let task = rx.lock().await.recv().await; if let Some(task) = task { let result = DenoRuntime::execute_inner( task.code, task.payload, task.headers ).await; let _ = task.response.send(result); } else { break; } } }); }); } Self { sender: tx } } pub async fn execute(&self, code: String, payload: Option, headers: HashMap) -> Result<(String, String, u16, HashMap)> { let (tx, rx) = oneshot::channel(); self.sender.send(DenoTask { code, payload, headers, response: tx }).await .map_err(|_| anyhow::anyhow!("Worker pool exhausted"))?; rx.await.map_err(|_| anyhow::anyhow!("Worker panicked"))? } } ``` Initialize in `gateway/src/worker.rs` with `DENO_POOL_SIZE` env var (default: 4). ### 6.2.5 Function deletion Add route in `functions/src/lib.rs`: ```rust .route("/:name", get(handlers::get_function) .post(handlers::invoke_function) .delete(handlers::delete_function)) ``` Handler deletes from DB. The function code is not stored on the filesystem in production. ### 6.2.6 Function logs Capture `console.log` output by intercepting the `Deno.core.print` calls: ```rust // In preamble, collect logs into an array globalThis.__logs__ = []; globalThis.console = { log: (...args) => { const msg = args.map(a => String(a)).join(" "); globalThis.__logs__.push({ level: "info", msg, ts: Date.now() }); Deno.core.print(msg + "\n"); }, // ... same for error, warn, debug }; ``` After execution, extract logs: ```rust let logs_val = runtime.execute_script("", "JSON.stringify(globalThis.__logs__)")?; // Deserialize and include in InvokeResponse ``` --- ## Completion Requirements This milestone is **not complete** until every item below is satisfied. ### 1. Full Test Suite — All Green - [ ] `cargo test --workspace` passes with **zero failures** - [ ] All **pre-existing tests** still pass (no regressions) - [ ] **New unit tests** are written for every feature in this milestone: | Test | Location | What it validates | |------|----------|-------------------| | `test_sandboxed_loader_blocks_etc_passwd` | `functions/src/deno_runtime.rs` | `resolve("/etc/passwd", ...)` returns an error | | `test_sandboxed_loader_blocks_parent_traversal` | `functions/src/deno_runtime.rs` | `resolve("../../etc/passwd", ...)` returns an error | | `test_sandboxed_loader_allows_local_import` | `functions/src/deno_runtime.rs` | `resolve("./helper.ts", ...)` within allowed dir succeeds | | `test_sandboxed_loader_allows_https_import` | `functions/src/deno_runtime.rs` | `resolve("https://deno.land/std/...", ...)` succeeds | | `test_sandboxed_loader_blocks_ftp` | `functions/src/deno_runtime.rs` | `resolve("ftp://...", ...)` returns an error | | `test_js_injection_safe_payload` | `functions/src/deno_runtime.rs` | Payload containing `'; process.exit(); '` does not crash the runtime | | `test_js_injection_safe_headers` | `functions/src/deno_runtime.rs` | Headers containing JS-breaking characters are safely passed | | `test_memory_limit_enforcement` | `functions/src/deno_runtime.rs` | Function allocating >128MB is terminated with an error | | `test_timeout_enforcement` | `functions/src/deno_runtime.rs` | Function with `while(true){}` is killed after configured timeout | | `test_typescript_execution` | `functions/src/deno_runtime.rs` | `.ts` function with type annotations compiles and executes | | `test_env_vars_accessible` | `functions/src/deno_runtime.rs` | `Deno.env.get('MY_VAR')` returns the configured value | | `test_fetch_api_available` | `functions/src/deno_runtime.rs` | `fetch('https://...')` resolves inside a function | | `test_worker_pool_concurrent` | `functions/src/deno_runtime.rs` | 10 concurrent invocations complete without thread exhaustion | | `test_function_deletion` | `functions/src/handlers.rs` | `DELETE /functions/v1/:name` removes the function and returns 204 | | `test_console_log_capture` | `functions/src/deno_runtime.rs` | `console.log("hello")` output appears in the invoke response | ### 2. Integration Verification - [ ] A function cannot `import '/etc/passwd'` — blocked by sandboxed loader - [ ] A function with `Deno.serve((req) => new Response("hello"))` works end-to-end - [ ] TypeScript functions compile and execute via the API - [ ] `fetch('https://httpbin.org/get')` works inside functions - [ ] Environment variables are accessible via `Deno.env.get()` - [ ] Function deletion via DELETE endpoint works - [ ] `console.log` output appears in the invoke response - [ ] Pool handles 10 concurrent invocations without thread exhaustion - [ ] Memory limit: a function allocating >128MB is terminated - [ ] Timeout: a function running >30s is terminated - [ ] `supabase.functions.invoke('my-func', { body: { key: 'value' } })` — round-trip works ### 3. CI Gate - [ ] All unit tests run in `cargo test --workspace` - [ ] Deno binary is available in the CI environment (or tests that require it are gated) - [ ] No `unsafe` code in the functions crate unless explicitly justified with a `// SAFETY:` comment