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

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:

  1. Add deno_fetch extension to the runtime
  2. Or implement a minimal fetch via Deno.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 --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