This document describes the security model, controls, and considerations for Devbox.
Devbox assumes:
- Trusted user on a trusted machine
- Potentially untrusted network (HTTPS required)
- Ephemeral servers (short-lived, deleted after use)
Devbox does NOT protect against:
- Compromised browser or machine
- Malicious browser extensions
- Physical access to the machine
- Targeted attacks against specific users
flowchart LR
subgraph Browser
App["Devbox SPA"]
LS["localStorage<br/>(secrets)"]
end
App <-->|HTTPS| API["Hetzner Cloud<br/>API"]
Security benefit: No central server holding user credentials. Each user's secrets stay on their machine.
Tradeoff: Relies entirely on browser security. No server-side validation or rate limiting.
| Credential | Storage Location | Encryption | Justification |
|---|---|---|---|
| Hetzner API token | localStorage | None | User-controlled, trusted machine assumed |
| Git credential | localStorage | None | Bootstrap credential for chezmoi/repo cloning |
| SSH public keys | localStorage | N/A | Public data |
| Auth user passwords (bcrypt) | localStorage | Hashed | Pre-provisioned for Authelia forward auth |
| Age key | localStorage | None | For chezmoi secret decryption |
| ACME EAB keys | localStorage | None | Optional, user-provided |
Why no encryption at rest?
Client-side encryption would require a user-provided password or key. This adds UX friction and the key would need to be stored somewhere (defeating the purpose) or entered each session. Given the trusted-machine assumption, plain localStorage is acceptable.
Future consideration: Optional encryption for sensitive fields using Web Crypto API with a user passphrase.
All API calls use HTTPS:
- Hetzner API:
https://api.hetzner.cloud - CSP restricts
connect-srcto only allow Hetzner API
Tokens are transmitted in:
Authorization: Bearer <token>header (Hetzner API) -- Secure- Authelia session cookies (service access) -- Secure, session-based
Cloud-init scripts contain:
- Bootstrap git credential (username + token for cloning chezmoi repo)
- Age private key (for chezmoi secret decryption)
- Hetzner API token (for auto-delete daemon)
- Authelia user database (bcrypt-hashed passwords)
Where this data lives on the server:
/var/lib/cloud/instance/user-data # Original cloud-init
/home/dev/.git-credentials # Git credential (0600)
/home/dev/.config/chezmoi/key.txt # Age key (0600)
/usr/local/bin/devbox-daemon # Contains Hetzner token
Most sensitive configuration (additional git credentials, API keys, env vars) is managed by chezmoi and decrypted on the server using the age key — not embedded directly in cloud-init.
Mitigations:
- File permissions: Sensitive files created with
0600(owner read/write only) - Ephemeral servers: Data exists only for server lifetime (typically < 1 day)
- User-controlled: User decides what credentials to include
- No persistence: Servers are deleted, not stopped/restarted
- Minimal cloud-init secrets: Only bootstrap credential in cloud-init; chezmoi handles the rest
All user input embedded in cloud-init is escaped:
// Shell context (double-quoted strings)
function shellEscape(s) {
return s.replace(/[\\"$`!]/g, '\\$&').replace(/\n/g, '');
}
// JavaScript string context (single-quoted)
function escapeSingleQuotedJS(s) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/<\//g, '<\\/');
}All dynamic content is escaped before rendering:
export function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Usage: Every user-controlled value (server names, profile names, config values) passes through escapeHtml() or escapeAttr() before DOM insertion.
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'none';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://api.hetzner.cloud;
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
object-src 'none';
"
/>| Directive | Value | Purpose |
|---|---|---|
default-src |
'none' |
Deny everything by default |
script-src |
'self' |
Only allow scripts from same origin (no inline) |
style-src |
'self' 'unsafe-inline' |
Allow own styles + dynamic inline styles |
connect-src |
'self' https://api.hetzner.cloud |
Restrict API calls |
img-src |
'self' data: |
Allow images + QR code data URIs |
frame-ancestors |
'none' |
Prevent clickjacking (cannot be embedded in iframes) |
object-src |
'none' |
Disable plugins/embeds (<object>, <embed>) |
Why unsafe-inline for styles?
Dynamic inline styles are required for:
- Theme preview color swatches (dynamic background colors)
- Progress bars (dynamic width percentages)
- Combobox filtering (show/hide via display property)
These cannot use CSS classes because the values are computed at runtime. The security impact is limited because:
- All user input is escaped before rendering
- Style injection alone cannot execute JavaScript
- The threat model assumes a trusted machine
Additional Security Headers
<meta http-equiv="X-Content-Type-Options" content="nosniff" />This prevents browsers from MIME-sniffing responses away from the declared content-type.
export function setNestedValue(obj, path, value) {
const keys = path.split('.');
const lastKey = keys.pop();
const target = keys.reduce((o, k) => {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') return {};
if (!o[k]) o[k] = {};
return o[k];
}, obj);
if (lastKey === '__proto__' || lastKey === 'constructor' || lastKey === 'prototype') return;
target[lastKey] = value;
}if (!/^(https?:\/\/|git@)[\w.@:\/~-]+$/.test(value)) {
// Reject
}let id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-');Services on provisioned servers use Authelia forward auth:
flowchart TD
Internet((HTTPS)) --> Caddy["Caddy (reverse proxy)"]
Caddy -->|"forward_auth"| Authelia["Authelia<br/>(session auth)"]
Caddy --> ttyd["ttyd (terminal)"]
Caddy --> DevServers["Dev servers"]
- Caddy delegates authentication to a local Authelia instance via
forward_auth - Users are pre-provisioned with bcrypt-hashed passwords in Authelia's file-based user database
- Session cookies enable single sign-on across all service subdomains
- For wildcard DNS, a
dev.subdomain prefix ensures cookies can be shared across subdomains (avoids Public Suffix List restrictions)
Caddy obtains certificates automatically via ACME:
- Default: Let's Encrypt
- Alternatives: ZeroSSL, Buypass, custom CA
On-demand TLS protection:
function verifyDomain(domain) {
// Only issue certs for domains matching expected pattern
// AND where the corresponding port is actively listening
const expected = new RegExp(`^(\\d+)\\.${baseDomain}$`);
const match = domain.match(expected);
if (!match) return false;
return isPortListening(parseInt(match[1]));
}This prevents certificate issuance for arbitrary subdomains.
Protected via CSP frame-ancestors 'none' directive.
Protected via X-Content-Type-Options: nosniff header.
Browser extensions can read localStorage and intercept requests. This is an accepted risk under the trusted-machine assumption.
Mitigation for high-security needs: Use browser profiles or incognito mode without extensions.
npm dependencies could be compromised.
Mitigations:
- Minimal dependencies (5 packages)
- Lock file (
pnpm-lock.yaml) pins versions - Regular updates and audits
If using wildcard DNS for services:
- Ensure DNS records are removed when servers are deleted
- Consider using IP-based URLs instead of subdomains for ephemeral servers
- Use HTTPS to access Devbox (especially on untrusted networks)
- Use a dedicated browser profile for sensitive work
- Disable unnecessary browser extensions
- Use Hetzner API tokens with minimal required permissions
- Delete servers when done (don't just stop them)
- Rotate Git tokens periodically
- Clear browser data if using shared/public machines
If you discover a security vulnerability, please report it privately rather than opening a public issue.