diff --git a/src/lib/server/environment-redact.ts b/src/lib/server/environment-redact.ts new file mode 100644 index 0000000..8157869 --- /dev/null +++ b/src/lib/server/environment-redact.ts @@ -0,0 +1,24 @@ +/** + * Strip an environment's decrypted secrets before it leaves the server. + * + * `getEnvironment(s)` decrypt `tlsKey` (the private TLS client key) and `hawserToken` + * for internal use, but neither must ever reach an API response: the TLS key + * authenticates directly against the Docker daemon, bypassing Dockhand entirely and + * surviving a revoked login. `tlsCa`/`tlsCert` are the public CA + client cert and + * are NOT secret, so they stay. In their place we expose `hasTlsKey` / `hasHawserToken` + * booleans so the UI can show "configured" without holding the value. + */ + +/** The two secret fields removed from every environment API response. */ +export type EnvironmentSecretField = 'tlsKey' | 'hawserToken'; + +export function redactEnvironment( + env: T +): Omit & { hasTlsKey: boolean; hasHawserToken: boolean } { + const { tlsKey, hawserToken, ...rest } = env; + return { + ...rest, + hasTlsKey: !!(tlsKey && tlsKey.length > 0), + hasHawserToken: !!(hawserToken && hawserToken.length > 0) + }; +} diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index 1298778..3375e30 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -9,13 +9,14 @@ import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; +import { redactEnvironment } from '$lib/server/environment-redact'; /** * @openapi * summary: List all Docker environments (hosts) known to Dockhand - * resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string}> - * resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition) - * resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin"},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin"}] + * resp-200: array<{id:integer!, name:string!, connectionType:string!, host:string, port:integer, protocol:string, icon:string, publicIp:string, timezone:string, hasTlsKey:boolean, hasHawserToken:boolean}> + * resp-200-desc: Environments accessible to the caller (filtered by RBAC in Enterprise mode; all environments in Free edition). The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored. + * resp-200-example: [{"id":1,"name":"hhdocker01","connectionType":"socket","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":"203.0.113.10","timezone":"Europe/Berlin","hasTlsKey":false,"hasHawserToken":false},{"id":2,"name":"hhdocker02","connectionType":"hawser-edge","host":null,"port":2375,"protocol":"http","icon":"server","publicIp":null,"timezone":"Europe/Berlin","hasTlsKey":false,"hasHawserToken":true}] * resp-403: Permission denied (RBAC 'environments:view' missing) * resp-500: Unexpected error while loading environments */ @@ -55,7 +56,7 @@ export const GET: RequestHandler = async ({ cookies }) => { const timezone = await getEnvironmentTimezone(env.id); const imagePruneSettings = await getImagePruneSettings(env.id); return { - ...env, + ...redactEnvironment(env), labels: parseLabels(env.labels as string | null), publicIp: publicIps[env.id.toString()] || null, updateCheckEnabled: updateSettings?.enabled || false, @@ -172,7 +173,7 @@ export const POST: RequestHandler = async (event) => { // Audit log await auditEnvironment(event, 'create', env.id, env.name); - return json(env); + return json(redactEnvironment(env)); } catch (error) { console.error('Failed to create environment:', error); return json({ error: 'Failed to create environment' }, { status: 500 }); diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 1eb3562..593cd5f 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -10,6 +10,7 @@ import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; +import { redactEnvironment } from '$lib/server/environment-redact'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; @@ -22,7 +23,8 @@ import { deleteEnvironmentIcon } from '$lib/server/env-icons'; * @openapi * summary: Get a single environment by id, including its parsed labels and public IP * path: id:integer! Environment id (from GET /api/environments) - * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string} + * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean} + * resp-200-desc: The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored. * resp-403: Permission denied (RBAC 'environments:view' missing) * resp-404: Environment not found * resp-500: Unexpected error while loading the environment @@ -47,7 +49,7 @@ export const GET: RequestHandler = async ({ params, cookies }) => { // Parse labels from JSON string to array return json({ - ...env, + ...redactEnvironment(env), labels: parseLabels(env.labels as string | null), publicIp }); @@ -62,8 +64,10 @@ export const GET: RequestHandler = async ({ params, cookies }) => { * summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories * path: id:integer! Environment id (from GET /api/environments) * body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string} + * body-desc: tlsKey/hawserToken are write-only - a blank value keeps the stored secret; a non-blank value replaces it. * body-example: {"name":"hhdocker03","collectMetrics":true} - * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string} + * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean} + * resp-200-desc: The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored. * resp-400: Invalid new name (rename validation) * resp-403: Permission denied (RBAC 'environments:edit' missing) * resp-404: Environment not found @@ -155,6 +159,13 @@ export const PUT: RequestHandler = async (event) => { ? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : []) : undefined; + // The GET/list responses never return the tlsKey / hawserToken secrets, so the + // edit form can't round-trip them. A blank value therefore means "keep the stored + // secret" (pass undefined so updateEnvironment leaves the column untouched); a + // non-blank value replaces it. Same "leave blank to keep" pattern as registries / + // git credentials. (Removing a secret entirely is done by deleting the env.) + const cleanedTlsKey = cleanPem(data.tlsKey); + const trimmedToken = typeof data.hawserToken === 'string' ? data.hawserToken.trim() : data.hawserToken; const env = await updateEnvironment(id, { name: data.name, host: data.host, @@ -162,7 +173,7 @@ export const PUT: RequestHandler = async (event) => { protocol: data.protocol, tlsCa: cleanPem(data.tlsCa), tlsCert: cleanPem(data.tlsCert), - tlsKey: cleanPem(data.tlsKey), + tlsKey: cleanedTlsKey || undefined, tlsSkipVerify: data.tlsSkipVerify, icon: data.icon, socketPath: data.socketPath, @@ -171,7 +182,7 @@ export const PUT: RequestHandler = async (event) => { highlightChanges: data.highlightChanges, labels: labels, connectionType: data.connectionType, - hawserToken: data.hawserToken + hawserToken: trimmedToken || undefined }); if (!env) { @@ -207,7 +218,7 @@ export const PUT: RequestHandler = async (event) => { // Parse labels from JSON string to array return json({ - ...env, + ...redactEnvironment(env), labels: parseLabels(env.labels as string | null), publicIp }); diff --git a/src/routes/environments/+page.svelte b/src/routes/environments/+page.svelte index 25a78e0..2aed711 100644 --- a/src/routes/environments/+page.svelte +++ b/src/routes/environments/+page.svelte @@ -121,7 +121,9 @@ formProtocol = env.protocol; formTlsCa = env.tlsCa || ''; formTlsCert = env.tlsCert || ''; - formTlsKey = env.tlsKey || ''; + // The API never returns the tlsKey secret; leave blank on edit (a blank submit + // keeps the stored key via `tlsKey: formTlsKey || undefined` below). + formTlsKey = ''; formError = ''; formSaving = false; showEditModal = true; diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index a1e0501..244d998 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -109,7 +109,10 @@ protocol: string; tlsCa?: string; tlsCert?: string; - tlsKey?: string; + // tlsKey / hawserToken are never returned by the API (write-only secrets); the + // has* flags say whether one is stored. + hasTlsKey?: boolean; + hasHawserToken?: boolean; tlsSkipVerify?: boolean; icon?: string; socketPath?: string; @@ -122,6 +125,7 @@ hawserAgentName?: string; hawserVersion?: string; hawserCapabilities?: string; + labels?: string; publicIp?: string; createdAt: string; updatedAt: string; @@ -281,6 +285,9 @@ let formTlsCa = $state(''); let formTlsCert = $state(''); let formTlsKey = $state(''); + // Whether the environment already has a stored tlsKey (the value itself is never + // returned by the API). Drives the "leave blank to keep" hint on the field. + let hasStoredTlsKey = $state(false); let formTlsSkipVerify = $state(false); let formIcon = $state('globe'); let pendingIconData = $state(null); @@ -303,6 +310,8 @@ const usesStackPath = (ct: ConnectionType) => ct === 'direct' || ct === 'hawser-standard' || ct === 'hawser-edge'; const isHawserConn = (ct: ConnectionType) => ct === 'hawser-standard' || ct === 'hawser-edge'; let formHawserToken = $state(''); + // Whether a hawser token is already stored (value never returned by the API). + let hasStoredHawserToken = $state(false); let formLabels = $state([]); let newLabelInput = $state(''); let showLabelDropdown = $state(false); @@ -578,7 +587,11 @@ formProtocol = environment.protocol; formTlsCa = environment.tlsCa || ''; formTlsCert = environment.tlsCert || ''; - formTlsKey = environment.tlsKey || ''; + // The API never returns the tlsKey / hawserToken secrets (they're stripped + // server-side). Start blank and remember whether one is stored so the field + // can say "leave blank to keep"; a blank submit keeps the existing secret. + formTlsKey = ''; + hasStoredTlsKey = !!environment.hasTlsKey; formTlsSkipVerify = environment.tlsSkipVerify ?? false; formIcon = environment.icon || 'globe'; formSocketPath = environment.socketPath || '/var/run/docker.sock'; @@ -586,7 +599,8 @@ formCollectMetrics = environment.collectMetrics ?? true; formHighlightChanges = environment.highlightChanges ?? true; formConnectionType = (environment.connectionType as ConnectionType) || 'socket'; - formHawserToken = environment.hawserToken || ''; + formHawserToken = ''; + hasStoredHawserToken = !!environment.hasHawserToken; formLabels = parseLabels(environment.labels); newLabelInput = ''; formPublicIp = environment.publicIp || ''; @@ -620,6 +634,7 @@ formTlsCa = ''; formTlsCert = ''; formTlsKey = ''; + hasStoredTlsKey = false; formTlsSkipVerify = false; formIcon = 'globe'; pendingIconData = null; @@ -633,6 +648,7 @@ formDiskWarningThresholdGb = 50; formConnectionType = 'socket'; formHawserToken = ''; + hasStoredHawserToken = false; formLabels = []; newLabelInput = ''; formPublicIp = ''; @@ -2137,7 +2153,7 @@ @@ -2245,7 +2261,7 @@ {/if} - generatedStandardToken = null} /> + generatedStandardToken = null} /> {#if generatedStandardToken}

diff --git a/tests/environment-redact.test.ts b/tests/environment-redact.test.ts new file mode 100644 index 0000000..2892559 --- /dev/null +++ b/tests/environment-redact.test.ts @@ -0,0 +1,54 @@ +/** + * redactEnvironment: strip the tlsKey / hawserToken secrets from an environment before + * it leaves the server, replacing them with hasTlsKey / hasHawserToken booleans. + * The private TLS client key authenticates straight to the Docker daemon, so it must + * never appear in an API response. + */ +import { describe, it, expect } from 'bun:test'; +import { redactEnvironment } from '../src/lib/server/environment-redact'; + +describe('redactEnvironment', () => { + it('removes tlsKey and hawserToken from the output', () => { + const out = redactEnvironment({ + id: 1, + name: 'prod', + tlsKey: '-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----', + hawserToken: 'super-secret-token', + tlsCa: 'CA', + tlsCert: 'CERT' + } as any); + expect('tlsKey' in out).toBe(false); + expect('hawserToken' in out).toBe(false); + // the JSON serialization a client would see must not contain the secret text + expect(JSON.stringify(out)).not.toContain('SECRET'); + expect(JSON.stringify(out)).not.toContain('super-secret-token'); + }); + + it('sets hasTlsKey / hasHawserToken true when a secret is present', () => { + const out = redactEnvironment({ id: 1, name: 'p', tlsKey: 'k', hawserToken: 't' } as any); + expect(out.hasTlsKey).toBe(true); + expect(out.hasHawserToken).toBe(true); + }); + + it('sets the flags false when the secret is null / empty', () => { + const out = redactEnvironment({ id: 1, name: 'p', tlsKey: null, hawserToken: '' } as any); + expect(out.hasTlsKey).toBe(false); + expect(out.hasHawserToken).toBe(false); + }); + + it('keeps the public tlsCa / tlsCert and all other fields', () => { + const out = redactEnvironment({ + id: 7, + name: 'p', + tlsCa: 'CA-PUBLIC', + tlsCert: 'CERT-PUBLIC', + connectionType: 'direct', + tlsKey: 'k', + hawserToken: 't' + } as any) as any; + expect(out.tlsCa).toBe('CA-PUBLIC'); + expect(out.tlsCert).toBe('CERT-PUBLIC'); + expect(out.connectionType).toBe('direct'); + expect(out.id).toBe(7); + }); +});