Files
madbase/_milestones/M6_edge_functions.md
Vlad Durnea cffdf8af86
Some checks failed
CI/CD Pipeline / unit-tests (push) Failing after 1m16s
CI/CD Pipeline / integration-tests (push) Failing after 2m32s
CI/CD Pipeline / lint (push) Successful in 5m22s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped
wip:milestone 0 fixes
2026-03-15 12:35:42 +02:00

322 lines
12 KiB
Markdown

# 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<deno_core::ModuleSpecifier, anyhow::Error> {
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::<Permissions>(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("<env>", 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<DenoTask>,
}
struct DenoTask {
code: String,
payload: Option<Value>,
headers: HashMap<String, String>,
response: oneshot::Sender<Result<(String, String, u16, HashMap<String, String>)>>,
}
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<Value>, headers: HashMap<String, String>)
-> Result<(String, String, u16, HashMap<String, String>)>
{
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("<logs>", "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