12 KiB
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:
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:
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):
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:
// 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:
// 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:
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:
- Add
deno_fetchextension to the runtime - Or implement a minimal
fetchviaDeno.core.ops
Option 1 (recommended):
// 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:
// 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:
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:
.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:
// 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:
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 --workspacepasses 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.logoutput 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
unsafecode in the functions crate unless explicitly justified with a// SAFETY:comment