Skip to content

Security: artalis-io/hull

Security

docs/security.md

Hull — Security Model

This document is brutally explicit about what Hull protects against, what it doesn't, and where trust anchors lie.


1. Trust Model Overview

Parties

Party Controls Must Trust
Platform publisher (gethull.dev) Platform library, signing key, build service Nothing (self-sovereign)
App developer Application code, signing key, manifest Platform publisher (or vendor their own)
End user Which apps to run, which keys to trust App developer + platform publisher
Third-party auditor Nothing — read-only verification Cryptographic math (Ed25519, SHA-256)

Key Insight

The system is designed so that no party requires blind trust:

  • Users can verify the platform (signature + canary + source audit)
  • Users can verify the app (signature + file hashes + manifest inspection)
  • Users can eliminate gethull.dev entirely (self-host, self-sign)

2. Signature Verification Chain

Three verification points, each catching different attack vectors:

When What Tool Checks
Before download Inspect capabilities verify/index.html (offline) Platform sig, app sig, manifest, canary
Before install Verify integrity hull verify --developer-key (CLI) Both sigs + file hashes
At startup Runtime check --verify-sig flag App sig + file hashes, platform key pin

Trust Anchors

Platform public key:

  • Hardcoded in Hull CLI (HL_PLATFORM_PUBKEY_HEX in signature.h)
  • Hardcoded in browser verifier (GETHULL_DEV_PLATFORM_KEY in verify/index.html)
  • Published at https://gethull.dev/.well-known/platform.pub
  • Overridable via --platform-key <file> for self-hosted platforms

Developer public key:

  • Published in app repository (.pub file)
  • Manually cross-referenced by user against trusted source
  • Passed explicitly: hull verify --developer-key dev.pub

3. Attack Model

A. Malicious App Developer

This is the primary threat model. Hull exists to make it possible to trust apps from unknown developers.

Attack: Ship a binary without the Hull platform (custom runtime, no sandbox)

  • Prevention: Platform signature + canary. Browser verifier checks platform sig against pinned gethull.dev key. Canary scanner finds HULL_PLATFORM_CANARY in the binary and verifies its integrity hash against the signed value.
  • Remaining risk: Developer could build a custom binary that includes the canary bytes at the right offset. Reproducible builds (Phase 9) close this gap entirely — a rebuild from source proves the binary is just "Hull platform + declared source."

Attack: Declare minimal manifest but access more at runtime

  • Prevention: Manifest is signed in package.sig. At runtime, pledge/unveil enforce the declared capabilities at the kernel level. Accessing undeclared paths triggers SIGKILL (Linux/Cosmo).
  • Remaining risk: macOS has no kernel sandbox — pledge/unveil are no-ops. C-level validation in capability functions is the only defense. A bug in hl_cap_fs_validate() on macOS would allow bypass. Linux and Cosmopolitan are kernel-enforced.

Attack: Call app.manifest() again at runtime to escalate capabilities

  • Prevention: Three independent barriers make this a non-issue:
    1. One-shot enforcement: app.manifest() errors on second call in both Lua and JS runtimes. The first call writes to a registry key; any subsequent call raises a runtime error ("app.manifest() can only be called once").
    2. Startup-only extraction: The manifest is read from the runtime into a C struct (HlManifest) once during startup (step 10 of the boot sequence). C-level capabilities (rt->env_cfg, rt->http_cfg, rt->csp_policy) are wired from this struct and never re-read from the runtime state.
    3. Kernel seal: unveil(NULL, NULL) seals filesystem visibility and pledge() restricts syscall families. Both are one-way operations — the kernel refuses to add permissions after sealing, regardless of what the runtime state says.
  • Even without the one-shot guard, a second app.manifest() call would only overwrite the Lua/JS registry key with no effect on the already-wired C capabilities or the sealed kernel sandbox. The guard exists to make the immutability explicit and prevent developer confusion.

Attack: SQL injection through user input

  • Prevention: All database access goes through hl_cap_db_query() / hl_cap_db_exec() which use SQLite parameterized binding (sqlite3_bind_*). SQL is always a literal string from app code. No string concatenation, ever. SQL injection is structurally impossible.

Attack: Path traversal to read /etc/passwd

  • Prevention: hl_cap_fs_validate() rejects:
    • Absolute paths (starts with /)
    • .. components
    • Any path that resolves outside the app's base directory via realpath() ancestor check
    • Symlink escapes (realpath resolves symlinks before checking)
  • Kernel unveil() also blocks access to undeclared paths.

Attack: Memory exhaustion / DoS via infinite allocation

  • Prevention:
    • Lua: Custom allocator enforces 64 MB heap limit. Exceeding → NULL allocation → script error, not crash.
    • QuickJS: JS_SetMemoryLimit() enforces 64 MB. Exceeding → allocation failure → JS exception.

Attack: Infinite loop / CPU exhaustion

  • Prevention:
    • QuickJS: Instruction-count interrupt handler. Configurable max_instructions limit. Exceeding → JS exception.
    • Lua: Memory limit eventually triggers (loops allocate stack frames). Less precise than instruction counting.

Attack: Exfiltrate data to unauthorized hosts

  • Prevention: hl_cap_http_request() validates the target host against the manifest's hosts allowlist. Only declared hosts are reachable. Kernel pledge includes inet + dns only if hosts are declared.

Attack: Read environment variables (API keys, secrets)

  • Prevention: hl_cap_env_get() checks against the manifest's env allowlist (max 32 entries). Undeclared variables return NULL.

Attack: Cross-site scripting (XSS) via template output

  • Prevention: Two layers of defense:
    1. Template auto-escaping: Hull's template engine (hull.template) HTML-escapes all {{ }} output by default. The five dangerous characters (& < > " ') are replaced with HTML entities. This prevents reflected and stored XSS from user-controlled data rendered into HTML templates.
    2. Content-Security-Policy (CSP): Hull injects a strict CSP header on every res:html() / res.html() response by default: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; frame-ancestors 'none'. This blocks inline scripts, external script loads, eval(), object embeds, and iframe embedding — even if an attacker bypasses template escaping, the browser refuses to execute injected scripts.
  • Remaining risk: Raw output ({{{ }}}) and the | raw filter bypass escaping. Developers must only use raw output with trusted content. Templates don't escape for JavaScript string contexts (e.g. inline <script> blocks) — use {{ var | json }} to safely embed data in JS contexts. Apps that require client-side JavaScript must customize the CSP (e.g. app.manifest({ csp = "default-src 'self'; script-src 'self'" })).

Attack: Clickjacking — embedding the app in a malicious iframe

  • Prevention: The default CSP includes frame-ancestors 'none', which instructs the browser to refuse rendering the page inside any <iframe>, <frame>, or <object> tag. This prevents UI redress attacks where a malicious site overlays invisible frames over the app to trick users into clicking hidden elements.
  • Actor: Any third-party website operator. Does not require compromising the app — just embedding it.

Attack: MIME type confusion / content sniffing

  • Prevention: The default CSP's default-src 'none' prevents the browser from loading any sub-resources (scripts, stylesheets, fonts, media) that an attacker might inject via reflected content. Combined with Content-Type: text/html; charset=utf-8 set by res:html(), the browser cannot misinterpret response content.
  • Actor: Network MITM or injection via stored user content.

Attack: Template injection (server-side template injection / SSTI)

  • Prevention: Template compilation uses luaL_loadbuffer / JS_Eval in the C bridge, which is only callable from embedded stdlib code (not user application code). The code generator produces deterministic output from the AST — user data is never interpolated into the generated source code. User data flows through the __d (data) parameter at render time, not at compile time. There is no eval() or load() in the sandboxed runtimes.

Attack: Session hijacking via cookie theft

  • Prevention: hull.cookie defaults to HttpOnly=true, Secure=true, SameSite=Lax. HttpOnly prevents JavaScript access (XSS-based theft). Secure prevents plaintext transmission. SameSite=Lax blocks cross-origin POST requests from carrying session cookies.
  • Remaining risk: Same-origin XSS can still read req.ctx.session data. Hull's template engine (hull.template) auto-escapes all {{ }} output by default (& < > " ' → HTML entities), which prevents most reflected and stored XSS vectors. Raw output via {{{ }}} or the | raw filter bypasses escaping and should only be used with trusted content.

Attack: CSRF — forged state-changing requests from another origin

  • Prevention: hull.middleware.csrf middleware generates HMAC-based tokens tied to the session ID and timestamp. State-changing methods (POST/PUT/DELETE/PATCH) require a valid CSRF token in the X-CSRF-Token header or _csrf form field. Tokens expire (default 1h). Safe methods (GET/HEAD/OPTIONS) are automatically skipped. Constant-time comparison prevents timing attacks.
  • Remaining risk: If the CSRF secret is leaked, tokens can be forged. The secret must be stored securely (e.g., env.get("SECRET_KEY")).

Attack: JWT token forgery

  • Prevention: hull.jwt uses HS256 with HMAC-SHA256 (no "none" algorithm, no algorithm negotiation). Signature verification uses constant-time comparison. Expired tokens are rejected.
  • Remaining risk: JWT secrets must be strong. JWTs are stateless — they cannot be revoked until they expire. For revocation, use sessions instead.

Attack: Session fixation / brute-force session IDs

  • Prevention: hull.middleware.session generates 32 random bytes (256-bit entropy) via crypto.random() for session IDs. IDs are hex-encoded (64 chars). Sessions are server-side (SQLite) with sliding expiry. Expired sessions are automatically pruned.

Browser-Level Security Headers

Hull injects security headers automatically at the C level to provide defense in depth:

Content-Security-Policy (CSP):

Default policy (applied to all res:html() / res.html() responses):

default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; frame-ancestors 'none'
Directive Value Blocks
default-src 'none' All resource types not explicitly allowed (scripts, fonts, media, objects, workers, WebSockets)
style-src 'unsafe-inline' External stylesheets (inline styles allowed for SSR convenience)
img-src 'self' Images from external origins
form-action 'self' Form submissions to external origins (data exfiltration via <form action="evil.com">)
frame-ancestors 'none' Embedding in iframes on any origin (clickjacking)

What the default CSP mitigates:

Attack Actor How CSP Blocks It
Reflected XSS (injected <script>) Any user who can craft a malicious URL default-src 'none' blocks inline script execution
Stored XSS (persisted <script>) Authenticated user who stores malicious content default-src 'none' blocks inline script execution
External script injection (<script src="evil.js">) Attacker who bypasses template escaping default-src 'none' blocks all external script loads
eval()-based XSS Attacker who injects data into JS eval context default-src 'none' implicitly disables eval() and Function()
Clickjacking (iframe embedding) Any third-party site operator frame-ancestors 'none' refuses rendering in iframes
Form action hijacking Attacker who injects <form action="evil.com"> form-action 'self' restricts form targets to same origin
Data exfiltration via <img src="evil.com/steal?data=..."> Attacker with XSS who tries to leak data via image tags img-src 'self' blocks images from external origins
Keylogging via injected external JS Attacker who loads a remote keylogger script default-src 'none' blocks all external resource loads

CSP configuration:

Manifest Behavior
No app.manifest() Default strict CSP (defense in depth)
app.manifest({}) Default strict CSP
app.manifest({ csp = "custom..." }) Custom CSP string
app.manifest({ csp = false }) CSP disabled (opt-out)

Where CSP is injected: At the C level in lua_res_html() and js_res_html(), not in application code. This means the CSP cannot be forgotten, bypassed, or misconfigured by app developers — it's structural, like parameterized SQL. Only res:json() and res:text() skip CSP (non-HTML content types are not vulnerable to script injection).

B. Malicious Third Party (MITM / CDN Compromise)

Attack: Replace binary on CDN with modified version

  • Prevention: binary_hash in package.sig is signed by the developer's Ed25519 key. Changed binary → hash mismatch. Browser verifier catches this immediately when binary is uploaded.

Attack: Replace package.sig with forged one

  • Prevention: Signature is Ed25519 over the canonical payload. Forging requires the developer's 32-byte private key. Ed25519 is considered secure against all known attacks.

Attack: Replace both binary and package.sig

  • Prevention: User verifies the developer's public key against a trusted source (e.g., GitHub repo, personal website). If the attacker doesn't have the developer's private key, they can't produce a valid signature for any payload.

Attack: Replace platform libraries in a self-hosted Hull

  • Prevention: Platform signature in package.sig is verified against the pinned gethull.dev key. Swapped platform → platform signature mismatch.

C. Compromised gethull.dev (Platform Publisher)

Attack: Ship malicious platform libraries

  • Prevention: Platform signing key is published. Users can:

    1. Audit Hull source (AGPL-3.0)
    2. Build their own platform: make platform
    3. Sign with own key: hull sign-platform mykey
    4. Pin their own key in apps

    The architecture is designed so you don't have to trust gethull.dev.

Attack: Backdoor the build service

  • Prevention: Reproducible builds. Anyone can rebuild from source with the recorded cc_version + flags and compare binary_hash. The build service is a convenience, not a trust requirement.

D. End User Who Doesn't Trust Anyone

Complete trust elimination path:

  1. Download Hull source from GitHub (AGPL-3.0)
  2. Audit the code
  3. Build platform yourself: make platform
  4. Sign with your own key: hull sign-platform mykey
  5. Distribute to customers with your key pinned
  6. Customers verify against YOUR key, not gethull.dev's

Trust chain: Customer → You (platform builder) → App developer. gethull.dev is completely out of the picture.


4. Sandbox Enforcement by Platform

Linux (gcc/clang + jart/pledge polyfill)

Mechanism Implementation Violation
Syscall filter seccomp-bpf via jart/pledge SIGKILL (unbypassable, kernel-enforced)
Filesystem restriction Landlock via jart/pledge EACCES or SIGKILL
Mode __pledge_mode = KILL_PROCESS | STDERR_LOGGING Process killed + diagnostic to stderr

Allowed pledge promises: stdio inet rpath wpath cpath flock dns (dns only if hosts declared)

CVE classes prevented:

  • Arbitrary file access outside declared paths
  • Privilege escalation via undeclared syscalls
  • Shell escape / command injection (no exec pledge)
  • Network exfiltration to undeclared hosts
  • Device access, mount, ptrace, raw sockets

Cosmopolitan APE (cosmocc)

Mechanism Implementation Violation
Syscall filter Native pledge() in cosmocc libc SIGKILL
Filesystem restriction Native unveil() ENOENT
Static binary No dynamic linking N/A

Additional protections:

  • Works on Linux, FreeBSD, OpenBSD, Windows (via NT security)
  • No dynamic linking → no LD_PRELOAD attacks
  • No DLL injection
  • No dynamic linker attacks
  • W^X enforcement by Cosmopolitan runtime

macOS (gcc/clang)

Mechanism Implementation Violation
Kernel sandbox Not available N/A
C-level validation Capability functions Returns error

Active defenses:

  • hl_cap_fs_validate() rejects path traversal
  • hl_cap_env_get() enforces allowlist
  • hl_cap_db_query() uses parameterized binding
  • hl_cap_http_request() validates host allowlist

Honest limitation: If a bug exists in the C validation layer, macOS has no kernel backup. A vulnerability in hl_cap_fs_validate() would allow filesystem access. On Linux/Cosmo, the kernel catches it anyway. This is a known, documented limitation.


5. What the Manifest Tells You

The manifest is the app's declared behavior contract:

app.manifest({
    fs = { read = {"data/"}, write = {"data/uploads/"} },
    env = {"PORT", "DATABASE_URL", "API_KEY"},
    hosts = {"api.stripe.com", "hooks.slack.com"}
})

What this tells an auditor:

  • This app reads from data/, writes to data/uploads/
  • It reads 3 environment variables
  • It makes HTTP calls to Stripe and Slack only
  • It has NO other filesystem, environment, or network access

How the system enforces it:

Level Enforcement Bypass
Kernel unveil() seals filesystem to declared paths SIGKILL on violation (Linux/Cosmo)
Kernel pledge() restricts to declared syscall families SIGKILL on violation (Linux/Cosmo)
C Every capability function validates against manifest Returns error on violation
Signature Manifest is signed — tampering invalidates signature Ed25519 forgery required

No manifest declared? Even if the app doesn't call app.manifest(), the default-deny posture is identical to app.manifest({}):

  • Kernel sandbox is applied — pledge/unveil restrict to only the database file and TLS paths
  • CSP is active — the default strict policy is injected on all HTML responses
  • C-level capabilities deny all — env returns NULL, HTTP requests fail, filesystem operations fail
  • Signature still covers the absence of manifest ("manifest": null)

All example apps declare app.manifest() explicitly, even when the empty {} is sufficient, as a best practice.


6. Verification Tools

A. Browser Verifier (verify/index.html)

  • Self-contained HTML file, zero server dependencies
  • Runs entirely in browser — no data sent anywhere
  • Inlined tweetnacl-js (public domain) for Ed25519
  • Web Crypto API for SHA-256
  • CSP: default-src 'none' — no network except optional key fetch via connect-src https:
  • gethull.dev platform key hardcoded — auto-verifies against known key
  • Canary scanner: searches uploaded binary for HULL_PLATFORM_CANARY magic bytes

Checks performed:

  1. Platform signature validity (Ed25519)
  2. Platform key match against pinned gethull.dev key
  3. App signature validity (Ed25519)
  4. Developer key match (if provided)
  5. Binary hash match (if binary uploaded)
  6. Platform canary presence + integrity (if binary uploaded)
  7. Source file hash verification (if files uploaded)
  8. Manifest capability display with risk levels

B. CLI Verifier (hull verify)

hull verify [--platform-key <file|url>] [--developer-key <file|url>] [app_dir]
  • Reads package.sig (or hull.sig for backwards compat)
  • Verifies platform layer Ed25519 signature
  • Verifies app layer Ed25519 signature
  • Recomputes SHA-256 of all declared files
  • Reports mismatches, missing files, key mismatches
  • Exit code 0 = all checks passed, 1 = failure

C. Runtime Verifier (--verify-sig)

./myapp --verify-sig dev.pub
  • Checks on every startup before accepting connections
  • Platform key pinned at compile time (HL_PLATFORM_PUBKEY_HEX)
  • Verifies both signature layers
  • Verifies file hashes against embedded entries via VFS (O(log n) lookup)
  • Refuses to start if any check fails

7. Trusted Rebuild Infrastructure (Future — Phase 9)

Service: api.gethull.dev/ci/v1

Flow

  1. Developer pushes source to GitHub
  2. CI calls api.gethull.dev/ci/v1/build
  3. Service rebuilds with exact cc_version + flags from package.sig
  4. Compares binary_hash
  5. If match → issues "Reproducible Build Verified" attestation
  6. Attestation is an Ed25519 signature over {binary_hash, timestamp, builder_version}

What This Proves

A passing reproducible build check proves the developer could not have injected custom native code. The binary is provably just "Hull platform + declared source files."

Why It Works

  1. App developers cannot write C — only Lua/JS source
  2. Platform binary is hash-pinned — platform.sig locks exact bytes
  3. Trampoline (app_main.c) is deterministic — generated from template
  4. Cosmopolitan produces deterministic output — static linking, no timestamps
  5. Build metadata is signed — cc_version + flags attested by developer

Self-Hosted Alternative

Run your own rebuild service. Pin your own platform key. Your customers trust you, not gethull.dev.


8. Keel HTTP Server Audit

The Keel HTTP server library (vendored at vendor/keel/) has been audited for memory safety, input validation, resource management, and network security. Full report: keel_audit.md.

Key findings relevant to security:

Severity Issue Impact
Critical kqueue kl_event_mod doesn't handle READ|WRITE bitmask HTTP/2 write starvation on macOS
Critical WebSocket ws_send_frame partial writes Frame corruption on non-blocking sockets
High HTTP/2 and WebSocket 101 upgrade partial writes Protocol stream corruption
High TLS private key material not zeroed before free Key residue in heap memory
Informational Request smuggling mitigation present Transfer-Encoding: chunked zeroes Content-Length (RFC 7230 §3.3.3)
Informational Header injection guard present contains_crlf() rejects \r/\n in header names/values

Build hardening verified:

  • -Wall -Wextra -Wpedantic -Wshadow -Wformat=2 -Werror in production
  • -fstack-protector-strong (non-Cosmopolitan builds)
  • ASan + UBSan debug build (make debug)
  • Two libFuzzer targets (HTTP parser + multipart parser)
  • 229 unit tests across 13 suites

9. Known Limitations

These are real, not theoretical:

Limitation Impact Mitigation
macOS has no kernel sandbox C-level validation bugs allow bypass Use Linux or Cosmo for production
Lua lacks instruction-count metering Infinite loops are only caught by memory limit QuickJS has precise gas metering
Canary is not foolproof Attacker could embed magic bytes in custom binary Reproducible builds (Phase 9) eliminate this
realpath() is TOCTOU Race between check and use Kernel unveil prevents actual access
Default CSP blocks client-side JS Apps needing fetch/AJAX must customize CSP app.manifest({ csp = "default-src 'self'; connect-src 'self'" })
32-entry limit per manifest category Large apps may hit ceiling Sufficient for most production apps
req.ctx uses raw malloc (not tracked) ctx JSON bypasses runtime memory limits Capped at 64KB; bounded by runtime heap indirectly
HMAC-SHA256 binding returns hex string Callers must use constant-time comparison hull.jwt and hull.middleware.csrf stdlib use constant-time internally

There aren’t any published security advisories