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
322 lines
12 KiB
Markdown
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
|