원클릭으로
security-hardening
Security patterns, guards, and best practices enforced across the Docklift codebase.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Security patterns, guards, and best practices enforced across the Docklift codebase.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Guide for server management, system APIs, backups, and maintenance operations.
Guide for developing features in the Vite + React Router frontend.
Guide to Docklift's automated release pipeline using semantic-release.
Guide for setting up, running, and developing the Docklift project.
Guide for setting up and managing Docklift's GitHub App integration.
Coolify/Dokploy-style managed databases with Dokku-style app linking.
| name | Security Hardening |
| description | Security patterns, guards, and best practices enforced across the Docklift codebase. |
This skill documents all security patterns implemented in Docklift. Follow these conventions when adding new features to maintain the security posture.
jsonwebtoken with JWT_SECRET from environment (auto-generated on first run if empty).pwdv claim: must equal User.passwordChangedAt.getTime(); middleware rejects mismatch or missing claim.authMiddleware from lib/authMiddleware.ts — never manually decode JWTs in route handlers.localStorage key docklift_token.authFetch() so 401 clears session app-wide.authFetch + blob pattern — never put JWTs in URL query parameters..bootstrap-secret (see authentication skill).purpose: 'sse').POST /api/auth/sse-token (Bearer session JWT required).?token=<sseToken> (not the long-lived session JWT).authMiddleware: Bearer session JWT only — query tokens are ignored/rejected.sseAuthMiddleware: query SSE token only — mounted exclusively on log stream routes (/api/system/logs/:service, /api/logs/.../stream/...).?token= cannot authorize DELETE/reboot or other protected APIs./api/auth routes: general limiter (100 / 15 min)./api/auth/register and /api/auth/setup-token also get setupLimiter (10 / 15 min).index.ts before the auth router.lib/stepUpAuth.ts — re-verify account password (JWT alone is not enough).POST /api/system/purge, /upgrade, /update-system, /reboot, /reset.purpose: terminal token.{ password } and abort when the operator cancels the prompt — never fire host actions on cancel.bcrypt with 12 salt rounds.index.ts)| Route | Access |
|---|---|
/api/auth/register, /login, /status | Public (rate limited) |
/api/github/webhook, /manifest/callback, /setup | Public (GitHub App flow; legacy OAuth /callback disabled) |
/api/backup/restore-upload with valid setup token | Public (one-time restore) |
All other /api/* routes | Requires JWT via authMiddleware |
X-Internal-Secret header used for backend-to-backend calls (e.g., webhook → deploy).INTERNAL_API_SECRET env var.lib/originCheck.ts is the single implementation, shared by the CORS middleware in index.ts and
the terminal WebSocket upgrade in services/terminal.ts.
RULE: An origin is trusted only when it matches scheme + host + port of the request the
browser actually made, or when it appears exactly in the operator allowlist (CORS_ORIGIN,
DOCKLIFT_FRONTEND_URL).
const allowed = isTrustedOrigin(origin, req.headers, {
fallbackProto: req.protocol,
allow: [config.frontendUrl],
});
requestOrigin() reconstructs the browser-facing origin from the forwarded Host/proto headers.Origin header (non-browser clients) are allowed — the JWT is still the gate.A previous refactor compared hostnames only. On a Docklift server that is exactly wrong: user apps are deployed on the same host under other ports and subdomains. Any co-located app could then make credentialed cross-origin requests to the dashboard API. Port and scheme are part of the origin — keep them in the comparison.
X-Forwarded-HostBecause requestOrigin() trusts the forwarded host, every proxy config sets it from the connection
itself:
proxy_set_header X-Forwarded-Host $http_host;
Without this line a client can send its own X-Forwarded-Host and forge the origin the backend
believes it is serving, defeating the same-origin check. Applies to nginx.conf and every template
in services/nginxSsl.ts.
RULE: Never use execSync() with string concatenation. Always use spawnSync() with argument arrays to prevent command injection.
// ✅ CORRECT — argument array, no shell injection possible
import { spawnSync } from 'child_process';
spawnSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
// ❌ WRONG — string interpolation allows injection
execSync(`docker rm -f ${containerName}`);
Applied in: deployments.ts (container teardown/migration) and projects.ts (volume lifecycle).
validateDockerBuildArgs() (services/compose.ts) passes only the build args a Dockerfile actually
declares with ARG. Forwarding every project env var into docker build would bake runtime secrets
into image layers, where they stay readable via docker history.
buildServiceImage() additionally rejects protected host variables (e.g. PATH, HOME,
DOCKER_HOST) as build variables, so a project setting cannot repoint the builder's toolchain or
Docker endpoint. Covered by buildResolver.test.ts.
resolveProjectPath() (services/buildResolver.ts) resolves base_directory / dockerfile_path
and rejects anything that escapes the deployment root, so a crafted project setting cannot make the
builder read from elsewhere on the host.
RULE: Never expose error.message in API responses. Always return generic messages.
// ✅ CORRECT
catch (error: any) {
console.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
// ❌ WRONG — leaks internal details
catch (error: any) {
res.status(500).json({ error: error.message });
}
Currently enforced in: auth.ts. Remaining: system.ts /version endpoint (low risk).
All streaming endpoints must use the writeLog guard to prevent crashes on client disconnect:
const writeLog = (text: string) => {
try { if (!res.writableEnded) res.write(text); } catch {}
logs.push(text);
};
Applied in: all 4 streaming handlers in deployments.ts (deploy, stop, restart, redeploy).
Also in docker.ts streamContainerLogs: uses safeWrite() + closed flag + res.on('close') cleanup.
crypto.timingSafeEqual (prevents timing attacks).github_webhook_secret stored in DB.req.rawBody) is captured via express.json({ verify }) callback for accurate HMAC comparison.recentDeploys Map with 10-second cooldown per project.GitHub installation tokens are set just-in-time and scrubbed after use (deploy pull and initial clone via scrubOriginRemote). After scrub, origin is verified to contain no credentials. If scrub/verify fails, fail the deploy or roll back project create — never continue with a token left in .git/config.
Applied in: deployments.ts (deploy handler), projects.ts (clone).
?token=).Terminal resize messages are validated to prevent injection:
if (!Number.isInteger(cols) || !Number.isInteger(rows) ||
cols < 1 || cols > 500 || rows < 1 || rows > 200) {
return; // silently ignore invalid resize
}
Applied in: terminal.ts.
files.ts)const resolved = path.resolve(projectDir, relativePath);
if (!resolved.startsWith(projectDir)) {
return res.status(403).json({ error: 'Access denied: path traversal detected' });
}
const realPath = fs.realpathSync(resolved);
if (!realPath.startsWith(projectDir)) {
return res.status(403).json({ error: 'Access denied: symlink escape' });
}
const projectIdRegex = /^[a-f0-9-]{36}$/;
Project uploads and backup archives are extracted through lib/safeUnzip.ts, which rejects absolute
paths and .. entries. A raw unzipper extract would happily write outside the destination
directory (zip-slip).
path.join(config.dataPath, 'uploads').try/finally:try {
// extract zip...
} finally {
try { fs.unlinkSync(req.file.path); } catch {}
}
index.ts via custom middleware: X-Content-Type-Options, X-Frame-Options,
X-XSS-Protection, Referrer-Policy, Permissions-Policy, and Strict-Transport-Security
(only when the request itself arrived over HTTPS). Helmet is not used.app.set('trust proxy', 1) — exactly one proxy (nginx). A larger value would let clients spoof
X-Forwarded-For and evade rate limiting.nginx-proxy/nginx.conf logs $uri rather than the full request line, keeping SSE ?token=
query parameters out of access logs.isTrustedOrigin(), plus exact CORS_ORIGIN entries — see
Origin Validation above.One-time token stored under config.dataPath (.setup-token) — never a hardcoded ./data path.
Middleware validates the token and sets req.setupTokenAuth but does not delete it yet.
Fresh-install restore skips password step-up (no admin user); authenticated restores still require it.
Commit gate (decideRestoreCommit): consume secrets only when reconcile OK and restored DB has
an admin. Incomplete setup restore rolls DB back and retains the token for retry.
All restore routes roll back from .pre-restore when a later stage throws.
Failed rollback → .restore-critical seal (persisted); further restores blocked until
POST /api/backup/clear-critical-restore with password step-up. Clear verifies deletion
(fail closed — do not exit maintenance if the marker remains).
Failed rollback → enterRestoreCritical (.restore-critical marker). Restores stay blocked across
restarts until POST /api/backup/clear-critical-restore with password step-up.
Frontend Setup page fetches token via GET /api/auth/setup-token (bootstrap secret required) and sends x-setup-token.
Backend handles SIGTERM/SIGINT for clean exit:
const shutdown = async (signal: string) => {
server.close(); // Stop accepting new connections
cleanupAllSessions(); // Kill all terminal PTY sessions
await prisma.$disconnect(); // Close database connection
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Applied in: index.ts.
When adding new endpoints or features, verify:
authMiddleware (or has explicit reason to be public)error.messagefinally blocksconsole.log(\[AUDIT]...`)`)spawnSync() with argument arrays, never execSync() with stringsisTrustedOrigin() — never compare hostnames aloneresolveProjectPath() / pathSecurity.tssafeUnzip.ts, never raw unzipperdocker buildX-Forwarded-Host from $http_hostdocklift_network by default.agent/skills + README/commands in the same PR