Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions base-devimage/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
inotify-tools \
iptables \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@infosupport/huddle-cli",
"version": "1.2.0",
"version": "1.3.0",
"description": "Huddle CLI — start devcontainers and manage firewall rules",
"license": "GPL-3.0-or-later",
"author": "Info Support B.V.",
Expand Down
15 changes: 12 additions & 3 deletions gateway/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,16 @@ export async function createApiServer(): Promise<FastifyInstance> {
Body: { domain: string; container_id?: string | null; status: RuleStatus; expires_at?: number | null; path_pattern?: string | null };
}>('/api/rules', async (req, reply) => {
const { domain, container_id = null, status, expires_at = null, path_pattern = null } = req.body;
if (!domain || !['requested', 'allow', 'deny'].includes(status)) {
// Eis expliciet een non-lege string: een truthy niet-string domain (bv. een
// getal/object in de JSON) zou anders verderop klappen met een 500 i.p.v.
// deze nette 400.
if (typeof domain !== 'string' || !domain || !['requested', 'allow', 'deny'].includes(status)) {
return reply.code(400).send({ error: 'invalid payload' });
}
// Domein opslaan zoals aangeleverd — géén casing-mutatie. De rule-engine
// matcht al hoofdletter-ongevoelig (COLLATE NOCASE in db.ts + canonicalizeHost/
// matchDomain, finding #3), dus lowercasen is overbodig en zou de echo-back
// naar clients veranderen.
try {
const info = db
.prepare(
Expand All @@ -402,9 +409,11 @@ export async function createApiServer(): Promise<FastifyInstance> {
.run(domain, container_id, status, expires_at, path_pattern);
const inserted = db.prepare(`SELECT * FROM rules WHERE id = ?`).get(info.lastInsertRowid) as Rule;
// Ruim alleen de host-only requested-rij op; padregels per domein blijven
// staan zodat fijnmazig beleid naast elkaar kan bestaan.
// staan zodat fijnmazig beleid naast elkaar kan bestaan. COLLATE NOCASE:
// requested-rijen worden lowercase aangemaakt (proxy/canonicalizeHost), dus
// matchen ook als de operator hier mixed-case aanlevert.
if (container_id === null && path_pattern === null && (status === 'allow' || status === 'deny')) {
db.prepare(`DELETE FROM rules WHERE domain = ? AND status = 'requested' AND path_pattern IS NULL`).run(domain);
db.prepare(`DELETE FROM rules WHERE domain = ? COLLATE NOCASE AND status = 'requested' AND path_pattern IS NULL`).run(domain);
}
logAudit({ containerId: container_id, domain, action: `admin:rule-${status}`, ruleId: Number(info.lastInsertRowid) });
notifyStateChanged();
Expand Down
16 changes: 13 additions & 3 deletions gateway/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,33 @@ export function initDb(): void {
db.exec('ALTER TABLE rules ADD COLUMN last_path TEXT');
}

// Domeinen worden voortaan canoniek (lowercase) opgeslagen zodat de exacte
// lookup en de wildcard-match op dezelfde vorm werken (finding #3). Migreer
// bestaande rijen idempotent naar lowercase VÓÓR de dedup hieronder, zodat
// case-varianten (`GIST.github.com` vs `gist.github.com`) samenvallen en de
// dedup ze tot één rij terugbrengt in plaats van op de unieke index te botsen.
db.exec('UPDATE rules SET domain = lower(domain) WHERE domain <> lower(domain)');

// Uniciteit geldt nu op (domain, container, pad): meerdere padregels per
// domein moeten naast elkaar kunnen bestaan. De oude domain+container index
// wordt vervangen.
// Opschonen voorkomt dat een migratie crasht wanneer oude data per ongeluk
// meerdere rijen met dezelfde unieke sleutel bevat.
// meerdere rijen met dezelfde unieke sleutel bevat. NOCASE in de GROUP BY
// zodat de dedup dezelfde hoofdletter-ongevoeligheid hanteert als de index.
db.exec(`
DELETE FROM rules
WHERE id NOT IN (
SELECT MAX(id)
FROM rules
GROUP BY domain, COALESCE(container_id, ''), COALESCE(path_pattern, '')
GROUP BY domain COLLATE NOCASE, COALESCE(container_id, ''), COALESCE(path_pattern, '')
)
`);
db.exec('DROP INDEX IF EXISTS idx_rules_domain_container');
// De oude index kon nog zonder NOCASE bestaan; herbouw hem case-insensitief.
db.exec('DROP INDEX IF EXISTS idx_rules_domain_container_path');
db.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rules_domain_container_path
ON rules (domain, COALESCE(container_id, ''), COALESCE(path_pattern, ''))`
ON rules (domain COLLATE NOCASE, COALESCE(container_id, ''), COALESCE(path_pattern, ''))`
);

// Seed the global allow rule for huddle's own domain so the sudo-audit
Expand Down
108 changes: 108 additions & 0 deletions gateway/src/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,62 @@ const DOCKER_SOCK_SYMLINK = `# Docker-toegang loopt via de socket in de gemounte
# (zie DOCKER_HOST). Symlink het defaultpad voor tools die DOCKER_HOST negeren.
ln -sfn /var/run/huddle/docker.sock /var/run/docker.sock 2>/dev/null || true`;

// Finding #15 (IDE-kanaal, VS Code Remote + JetBrains Gateway): het attach-kanaal
// loopt over `docker exec`/stdio en wordt door NOCH de egress-proxy NOCH de
// socket-proxy gezien. Het echte host-token komt NOOIT als bestand binnen; VS
// Code laat de container het on-demand ophalen. De werkelijke route (bevestigd
// live) is een git `credential.helper` die bij attach in ZOWEL /etc/gitconfig ALS
// de van de host gekopieerde ~/.gitconfig wordt gezet:
// helper = !… node /tmp/vscode-remote-containers-<id>.js git-credential-helper …
// die via de algemene remote-containers IPC-socket de host-credential-helper
// aanroept. (Oudere VS Code gebruikte GIT_ASKPASS + /tmp/vscode-git-*.sock; die
// dekken we ook nog af.) We knippen het op drie niveaus:
// 1. env-scrub voor ELKE shell — /etc/profile.d (login) én /etc/bash.bashrc
// (interactive non-login; wat de VS Code-terminal standaard sourcet). Dekt
// de GIT_ASKPASS-variant.
// 2. de git `credential.helper` die naar de remote-containers-helper wijst
// strippen uit /etc/gitconfig én ~/.gitconfig — dít is de daadwerkelijke
// route. Value-regex 'vscode-remote-containers' zodat een door de gebruiker
// zélf gezette helper blijft staan.
// 3. de doorgestuurde GPG-agent-socket(s) (~/.gnupg/S.gpg-agent*) weghalen —
// daarmee vervalt commit-signing met de host-GPG-key.
// 4. de oude askpass-sockets opruimen (voor VS Code-versies die die nog maken).
// De remote-containers IPC-socket zelf NIET verwijderen: die is multiplexed met
// de hele Remote-sessie; strippen we de helper, dan heeft git er toch geen route
// meer naartoe. Een guard draait de hele container-levensduur, want de helper/
// sockets verschijnen pas bij attach (ná dit script) en keren terug bij reconnect.
const IDE_CRED_SCRUB = `# Finding #15: ontneem de untrusted container de door de IDE doorgestuurde
# host-credentials (git-token via credential.helper/askpass, SSH-agent, GPG).
SCRUB_VARS='GIT_ASKPASS SSH_AUTH_SOCK SSH_AGENT_PID GPG_AGENT_INFO GPG_TTY VSCODE_GIT_ASKPASS_NODE VSCODE_GIT_ASKPASS_MAIN VSCODE_GIT_ASKPASS_EXTRA_ARGS VSCODE_GIT_IPC_HANDLE'
SCRUB_LINE="unset \$SCRUB_VARS"
printf '%s\\n' "\$SCRUB_LINE" > /etc/profile.d/99-huddle-scrub-ide-creds.sh
chmod 644 /etc/profile.d/99-huddle-scrub-ide-creds.sh
# Interactive non-login shells (o.a. de VS Code-terminal) lezen /etc/profile.d
# NIET; /etc/bash.bashrc is daar de plek.
grep -qF "\$SCRUB_LINE" /etc/bash.bashrc 2>/dev/null || printf '%s\\n' "\$SCRUB_LINE" >> /etc/bash.bashrc
# ~/.gnupg moet bestaan (mode 700) zodat de inotify-watch erop kan starten, ook
# als de IDE de GPG-socket pas later neerzet.
install -d -m 700 -o vscode -g vscode /home/vscode/.gnupg 2>/dev/null || true
# Credential-guard: strip de doorgestuurde git-credential-helper + ruim de
# GPG-agent- en oude askpass-sockets op. Idempotent, zodat een herhaalde run
# niets herschrijft.
_huddle_cred_guard() {
for cfg in /etc/gitconfig /home/vscode/.gitconfig; do
[ -f "\$cfg" ] && git config --file "\$cfg" --unset-all credential.helper 'vscode-remote-containers' 2>/dev/null || true
done
rm -f /home/vscode/.gnupg/S.gpg-agent /home/vscode/.gnupg/S.gpg-agent.* 2>/dev/null || true
find /tmp -maxdepth 1 \\( -name 'vscode-git-*.sock' -o -name 'vscode-ssh-auth-*.sock' \\) -delete 2>/dev/null || true
}
_huddle_cred_guard # ruim op wat er bij attach al stond
if command -v inotifywait >/dev/null 2>&1; then
# Reageer meteen als de IDE de helper/sockets (opnieuw) neerzet (race ~sub-ms).
( inotifywait -q -m -e create -e modify -e moved_to --format '%f' /tmp /etc /home/vscode /home/vscode/.gnupg 2>/dev/null | while IFS= read -r f; do
case "\$f" in gitconfig|.gitconfig|S.gpg-agent|S.gpg-agent.*|vscode-git-*.sock|vscode-ssh-auth-*.sock) _huddle_cred_guard ;; esac
done ) &
else
( while true; do _huddle_cred_guard; sleep 1; done ) &
fi`;

// ── jb-config.sh — same logic as devcontainer-manager.ps1 ───────────────────

function buildJbConfigScript(containerWorkspace: string, containerName: string, ideName: IdeName, password: string, caCertPem: string, seedScript: string): string {
Expand Down Expand Up @@ -494,6 +550,8 @@ command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/de
printf 'export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/huddle-ca.crt\\n' > /etc/profile.d/99-huddle-ca.sh
chmod 644 /etc/profile.d/99-huddle-ca.sh

${IDE_CRED_SCRUB}

# De JetBrains-IDE (IntelliJ/Rider) draait op de JBR, een eigen JVM die TLS niet
# tegen de system store of NODE_EXTRA_CA_CERTS valideert maar tegen z'n eigen
# cacerts-keystore. Zonder import hieronder weigert de IDE het MITM-leaf-cert en
Expand Down Expand Up @@ -556,8 +614,47 @@ fi
// Zelfde firewall/sudo/audit-setup als de JB-flow, maar zónder JB host-config en
// zónder remote-dev-server: VS Code installeert zijn eigen backend (VS Code Server)
// bij het attachen. Houd dit in sync met de vscode-branch in huddle.ps1.
// Machine-level VS Code Remote-instellingen die het IDE-kanaal hardenen
// (finding #15). Het VS Code-Remote-kanaal loopt over `docker exec`/stdio en
// wordt NOCH door de egress-proxy NOCH door de socket-proxy gezien — het is een
// derde brug tussen host en (untrusted) container. Zonder deze policy erft een
// in-container terminal (waar een AI-agent draait) de door VS Code doorgestuurde
// host-credentials, en draait een aanvaller-gecontroleerde `tasks.json`
// automatisch bij het openen van de map. Deze settings sluiten dat:
// - terminal.integrated.env.linux → null de doorgestuurde credential-env, zodat
// terminals/agents GIT_ASKPASS / SSH_AUTH_SOCK / GPG e.d. niet meer zien
// (VS Code's eigen git-integratie via de extension-host blijft werken).
// - task.allowAutomaticTasks=off → geen folderOpen-autorun.
// - security.workspace.trust.* → open mappen starten in Restricted Mode.
// - terminal.integrated.allowLocalTerminal=false → blokkeer het openen van een
// HOST-terminal vanuit het remote-venster (newLocal).
// Volledig dichttimmeren vereist dat Huddle de attach zelf beheert (managed
// devcontainer.json met copyGitConfig:false); dit is de container-side laag.
export function buildVscodeMachineSettings(): Record<string, unknown> {
return {
'security.workspace.trust.enabled': true,
'security.workspace.trust.startupPrompt': 'always',
'security.workspace.trust.banner': 'always',
'security.workspace.trust.emptyWindow': false,
'task.allowAutomaticTasks': 'off',
'terminal.integrated.allowLocalTerminal': false,
// null verwijdert de variabele uit de terminal-omgeving.
'terminal.integrated.env.linux': {
GIT_ASKPASS: null,
VSCODE_GIT_ASKPASS_NODE: null,
VSCODE_GIT_ASKPASS_MAIN: null,
VSCODE_GIT_ASKPASS_EXTRA_ARGS: null,
VSCODE_GIT_IPC_HANDLE: null,
SSH_AUTH_SOCK: null,
GPG_AGENT_INFO: null,
GPG_TTY: null,
},
};
}

function buildVscodeConfigScript(containerWorkspace: string, containerName: string, password: string, caCertPem: string, seedScript: string): string {
const caB64 = Buffer.from(caCertPem, 'utf8').toString('base64');
const settingsB64 = Buffer.from(JSON.stringify(buildVscodeMachineSettings(), null, 2), 'utf8').toString('base64');
return `#!/bin/sh
CURL_LINE='--proxy-header "X-Container-ID: ${containerName}"'
grep -qF "$CURL_LINE" /home/vscode/.curlrc 2>/dev/null || echo "$CURL_LINE" >> /home/vscode/.curlrc
Expand All @@ -580,6 +677,8 @@ command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/de
printf 'export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/huddle-ca.crt\\n' > /etc/profile.d/99-huddle-ca.sh
chmod 644 /etc/profile.d/99-huddle-ca.sh

${IDE_CRED_SCRUB}

# Install sudo + passwd if missing (update index first; base image wipes /var/lib/apt/lists)
export DEBIAN_FRONTEND=noninteractive
command -v sudo >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y --no-install-recommends sudo passwd; }
Expand All @@ -594,6 +693,15 @@ chmod -R u+rwX "${containerWorkspace}" 2>/dev/null || true

${seedScript}

# Finding #15: harden het VS Code Remote IDE-kanaal met machine-level settings.
# Attach-to-running-container leest deze uit ~/.vscode-server/data/Machine/ (en
# de insiders-variant). We schrijven ze voor de attach zodat ze meteen gelden.
for VSCODE_HOME in /home/vscode/.vscode-server /home/vscode/.vscode-server-insiders; do
mkdir -p "$VSCODE_HOME/data/Machine"
echo '${settingsB64}' | base64 -d > "$VSCODE_HOME/data/Machine/settings.json"
done
chown -R vscode:vscode /home/vscode/.vscode-server /home/vscode/.vscode-server-insiders 2>/dev/null || true

# Configure sudo audit logging
mkdir -p /etc/sudoers.d
printf 'Defaults logfile=/tmp/sudo-audit.log\\n' > /etc/sudoers.d/99-huddle-audit
Expand Down
36 changes: 36 additions & 0 deletions gateway/src/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AdmZip from 'adm-zip';
import path from 'path';
import fs from 'fs';
import net from 'net';
import crypto from 'crypto';
import type { FastifyInstance } from 'fastify';
import type { Database } from 'better-sqlite3';
import { stateEvents } from '../events';
Expand Down Expand Up @@ -149,9 +150,44 @@ function parseManifest(raw: string): ExtensionManifest {
return manifest;
}

// ── Extensie-integriteit (finding #11) ──────────────────────────────────────
// Een geüploade extensie draait IN-PROCESS in de gateway (await import → raw
// docker.sock) = host-root-equivalent. Sinds de operator-auth zit de upload
// achter authenticatie, maar dat beschermt niet tegen een operator die een
// gemanipuleerde/kwaadaardige bundel uploadt. Daarom: bereken de SHA-256 van de
// bundel en toets die tegen een pinned allowlist. Consistent met het Phase 0-
// patroon (HUDDLE_HOSTCONFIG_ENFORCE): standaard LOG-ONLY (logt de hash zodat de
// operator hem kan pinnen); zet HUDDLE_EXTENSION_SHA256_ALLOWLIST (komma-
// gescheiden hashes) om alleen die bundels toe te laten en de rest te weigeren.
export function bundleSha256(zipBuffer: Buffer): string {
return crypto.createHash('sha256').update(zipBuffer).digest('hex');
}

// Retourneert een weigeringsreden, of null wanneer de bundel is toegestaan.
export function checkExtensionIntegrity(zipBuffer: Buffer): string | null {
const hash = bundleSha256(zipBuffer);
const raw = process.env.HUDDLE_EXTENSION_SHA256_ALLOWLIST?.trim();
if (!raw) {
console.warn(
`[ext] integriteit (log-only): bundel sha256=${hash}. ` +
`Zet HUDDLE_EXTENSION_SHA256_ALLOWLIST=${hash}[,…] om uploads tot vertrouwde bundels te beperken.`,
);
return null;
}
const allow = new Set(raw.split(',').map(h => h.trim().toLowerCase()).filter(Boolean));
if (!allow.has(hash.toLowerCase())) {
return `extension bundle sha256 ${hash} is not on HUDDLE_EXTENSION_SHA256_ALLOWLIST`;
}
return null;
}

export async function installExtension(
zipBuffer: Buffer,
): Promise<{ id: string; name: string; restartRequired: boolean }> {
// Integriteit vóór we ook maar iets uitpakken of laden (fail-closed).
const integrityError = checkExtensionIntegrity(zipBuffer);
if (integrityError) throw new Error(integrityError);

const zip = new AdmZip(zipBuffer);

const manifestEntry = zip.getEntry('manifest.json');
Expand Down
Loading
Loading