Security hardening: red-team remediation + CCP/WIP updates

## Security (red-team audit 2026-04-12)

Public data exposure (P0):
- Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing,
  no addresses/support-levels/sign-info returned
- Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry
- Petition public-stats drops recentSigners entirely
- Response wall strips userComment + submittedByName
- Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN

Access control (P1):
- Campaign findById/update/delete/email-stats enforce owner === req.user.id
  (SUPER_ADMIN bypasses), return 404 to avoid enumeration
- GPS tracking session route restricted to session owner or SUPER_ADMIN
- Canvass volunteer stats restricted to self or SUPER_ADMIN
- People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*)
- CCP upgrade.service.ts + certificate.service.ts gate user-controlled
  shell inputs (branch, path, slug, SAN hostname) behind regex validators

Token security (P2):
- Query-param JWT auth replaced with HMAC-signed short-lived URLs
  (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed
  from media streaming, photos, chat-notifications, and social SSE
- GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars);
  JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments
- Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT
  claim; mismatch revokes all user sessions
- Refresh expiry reduced 7d → 24h
- Refresh/logout via request body removed — httpOnly cookie only
- Password-reset + verification-resend rate limits now keyed on (IP, email)
  composite to prevent both IP rotation and email enumeration

Defense-in-depth (P3):
- DOMPurify sanitization applied to GrapesJS landing page HTML/CSS
- /api/health?detailed=true disk-space leak removed
- Password-reset/verification token log lines no longer include userId

## Deployment

- docker-compose.yml + docker-compose.prod.yml: media-api now receives
  GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed
- CCP templates/env.hbs adds both new secrets; refresh expiry → 24h
- CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt
- leaflet.heat added to admin/package.json for heatmap rendering

## Operator action required on existing installs

Run `./config.sh` once (idempotent — only fills empty values) or manually
add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via
`openssl rand -hex 32`. Startup fails with a clear Zod error otherwise.

See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix.

## Other

Includes in-flight CCP work: instance schema tweaks, agent server updates,
health service, tunnel service, DEV_WORKFLOW doc updates, and new migration
dropping composeProject uniqueness.

Bunker Admin
This commit is contained in:
2026-04-12 15:17:00 -06:00
parent 26ec925d9b
commit e55bc07eb6
59 changed files with 1387 additions and 510 deletions

View File

@@ -131,6 +131,7 @@ export default function InstanceDetailPage() {
const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
const [tunnelSyncing, setTunnelSyncing] = useState(false);
const [tunnelImporting, setTunnelImporting] = useState(false);
// Upgrade state
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
@@ -337,6 +338,26 @@ export default function InstanceDetailPage() {
}
}, [instance?.status, fetchInstance]);
// Fetch tunnel status for remote instances (must be before early return)
const fetchTunnelStatus = useCallback(async () => {
if (!instance?.isRemote) return;
setTunnelStatusLoading(true);
try {
const { data } = await api.get(`/instances/${id}/tunnel/status`);
setTunnelStatus(data.data);
} catch {
setTunnelStatus(null);
} finally {
setTunnelStatusLoading(false);
}
}, [id, instance?.isRemote]);
useEffect(() => {
if (activeTab === 'tunnel' && instance?.isRemote) {
fetchTunnelStatus();
}
}, [activeTab, instance?.isRemote, fetchTunnelStatus]);
const handleAction = async (action: string, label: string) => {
setActionLoading(action);
try {
@@ -1162,31 +1183,11 @@ export default function InstanceDetailPage() {
const tunnelConfigured = !!(instance.pangolinEndpoint && instance.pangolinNewtId);
const canConfigureTunnel = isManaged && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
// Fetch tunnel status for remote instances
const fetchTunnelStatus = useCallback(async () => {
if (!isRemote) return;
setTunnelStatusLoading(true);
try {
const { data } = await api.get(`/instances/${id}/tunnel/status`);
setTunnelStatus(data.data);
} catch {
setTunnelStatus(null);
} finally {
setTunnelStatusLoading(false);
}
}, [id, isRemote]);
useEffect(() => {
if (activeTab === 'tunnel' && isRemote) {
fetchTunnelStatus();
}
}, [activeTab, isRemote, fetchTunnelStatus]);
const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
setTunnelSetupRunning(true);
try {
await api.post(`/instances/${id}/tunnel/setup`, {
subdomainPrefix: values.subdomainPrefix || instance.slug,
subdomainPrefix: values.subdomainPrefix || '',
});
message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
fetchInstance();
@@ -1199,6 +1200,23 @@ export default function InstanceDetailPage() {
}
};
const handleTunnelImport = async () => {
setTunnelImporting(true);
try {
const { data } = await api.post(`/instances/${id}/tunnel/import`);
message.success(
`Tunnel imported — site ${data.data.siteId} (${data.data.online ? 'online' : 'offline'})`
);
fetchInstance();
fetchTunnelStatus();
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: { message?: string } } } };
message.error(e?.response?.data?.error?.message || 'Import failed');
} finally {
setTunnelImporting(false);
}
};
const handleTunnelSync = async () => {
setTunnelSyncing(true);
try {
@@ -1344,16 +1362,34 @@ export default function InstanceDetailPage() {
showIcon
/>
<Card title="Import Existing Tunnel" size="small">
<p style={{ marginTop: 0 }}>
If this instance already has a Pangolin tunnel set up (e.g. by
<code> config.sh --pangolin-site new</code> during install), the CCP can
adopt it by reading the remote <code>.env</code> and verifying the site
exists in the CCP&apos;s Pangolin org. No resources are modified.
</p>
<Popconfirm
title="Import existing tunnel?"
description="The CCP will read Pangolin credentials from the remote .env and persist them on this instance."
onConfirm={handleTunnelImport}
okText="Import"
>
<Button icon={<CloudOutlined />} loading={tunnelImporting}>
Import Existing Tunnel
</Button>
</Popconfirm>
</Card>
<Card title="Setup Tunnel" size="small">
<Form layout="vertical" onFinish={handleRemoteTunnelSetup}>
<Form.Item
name="subdomainPrefix"
label="Subdomain Prefix"
initialValue={instance.slug}
extra={`Resources will be created as <prefix>-app.${instance.domain}, <prefix>-api.${instance.domain}, etc.`}
rules={[{ required: true }, { pattern: /^[a-z0-9-]+$/, message: 'Lowercase alphanumeric + hyphens only' }]}
label="Subdomain Prefix (optional)"
extra={`Leave empty for standard subdomains (app.${instance.domain}, api.${instance.domain}). Set a prefix for multi-tenant domains (e.g. "ck" creates ck-app.${instance.domain}).`}
rules={[{ pattern: /^[a-z0-9-]*$/, message: 'Lowercase alphanumeric + hyphens only' }]}
>
<Input placeholder={instance.slug} />
<Input placeholder="(none — uses standard subdomains)" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" icon={<CloudOutlined />} loading={tunnelSetupRunning}>

View File

@@ -122,20 +122,39 @@ async function startPhoneHome() {
const result = await response.json() as { registrationId: string };
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
// Step 2: Poll for approval
// Step 2: Poll for approval. Every path inside the callback is wrapped in
// try/catch so an unexpected throw never kills the interval silently.
// On every poll we log either the status transition or a heartbeat every
// 10th attempt, so admins can see the loop is alive.
let pollCount = 0;
let lastLoggedStatus: string | null = null;
const pollInterval = setInterval(async () => {
pollCount += 1;
try {
const pollResp = await fetch(
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
);
if (!pollResp.ok) return;
if (!pollResp.ok) {
logger.warn(`[phone-home] Poll #${pollCount} HTTP ${pollResp.status} ${pollResp.statusText}`);
return;
}
const pollData = await pollResp.json() as {
status: string;
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
message?: string;
};
// Log status transitions and periodic heartbeats so the loop is never
// invisible. Previously a stuck loop left no trace in logs.
if (pollData.status !== lastLoggedStatus) {
logger.info(`[phone-home] Poll #${pollCount}: status=${pollData.status}${pollData.message ? `${pollData.message}` : ''}`);
lastLoggedStatus = pollData.status;
} else if (pollCount % 10 === 0) {
logger.debug(`[phone-home] Poll #${pollCount}: still ${pollData.status}`);
}
if (pollData.status === 'APPROVED' && pollData.certBundle) {
clearInterval(pollInterval);
logger.info('[phone-home] Approved! Saving certificates...');
@@ -161,14 +180,28 @@ async function startPhoneHome() {
// Exit so Docker restart policy brings us back with certs
process.exit(0);
} else if (pollData.status === 'APPROVED' && !pollData.certBundle) {
// Admin approved but cert bundle was consumed (e.g. by debug curl).
// Keep polling — admin can re-issue certs via the new endpoint and we'll
// pick them up on the next poll.
// (No action needed; the status-transition log above covers visibility.)
} else if (pollData.status === 'REJECTED') {
clearInterval(pollInterval);
logger.error('[phone-home] Registration was rejected by CCP admin');
}
} catch (err) {
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
// CRITICAL: this catch MUST swallow every error — if it rethrows the
// setInterval callback becomes an unhandled rejection and Node may kill
// the interval depending on the runtime config. We saw this in prod.
logger.warn(`[phone-home] Poll #${pollCount} failed: ${(err as Error).message}`);
}
}, 30_000);
// Defensive: if the Node process receives an unhandled rejection that
// somehow originates from the poll path, log it instead of dying quietly.
process.on('unhandledRejection', (reason) => {
logger.error(`[phone-home] Unhandled rejection in poll loop: ${reason}`);
});
} catch (err) {
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
}

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "instances_compose_project_key";

View File

@@ -66,7 +66,7 @@ model Instance {
statusMessage String? @map("status_message")
basePath String @map("base_path")
composeProject String @unique @map("compose_project")
composeProject String @map("compose_project")
gitBranch String @default("v2") @map("git_branch")
gitCommit String? @map("git_commit")

View File

@@ -245,4 +245,49 @@ router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN'
res.json({ message: 'Registration rejected' });
});
/**
* POST /api/agents/registrations/:id/reissue-certs
* Re-issue certificates for an approved registration whose cert bundle was already
* delivered and wiped (e.g. agent missed the one-shot delivery).
*/
router.post('/registrations/:id/reissue-certs', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
const { id } = req.params;
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
if (!registration) throw new AppError(404, 'Registration not found');
if (registration.status !== AgentRegistrationStatus.APPROVED) {
throw new AppError(400, `Registration is ${registration.status}, not APPROVED`);
}
if (!registration.instanceId) {
throw new AppError(400, 'Registration has no linked instance');
}
// Re-issue certs and write back to registration for agent to pick up
const certMaterials = await issueAgentCert(registration.instanceId, registration.slug, registration.agentUrl);
await prisma.agentRegistration.update({
where: { id: id as string },
data: {
certBundle: {
caCertPem: certMaterials.caCertPem,
agentCertPem: certMaterials.agentCertPem,
agentKeyPem: certMaterials.agentKeyPem,
ccpFingerprint: certMaterials.fingerprint,
},
},
});
await prisma.auditLog.create({
data: {
userId: (req as unknown as { user: { id: string } }).user.id,
instanceId: registration.instanceId,
action: AuditAction.AGENT_APPROVE,
details: { slug: registration.slug, reason: 'cert-reissue' },
ipAddress: req.ip || null,
},
});
logger.info(`[agents] Certificates re-issued for ${registration.slug}`);
res.json({ message: 'Certificates re-issued — agent will receive them on next poll' });
});
export default router;

View File

@@ -250,6 +250,20 @@ router.post(
}
);
// Adopt a tunnel that was set up outside CCP (e.g. by config.sh --pangolin-site new)
router.post(
'/:id/tunnel/import',
requireRole('SUPER_ADMIN'),
async (req: Request, res: Response) => {
const result = await tunnelService.importTunnel(
req.params.id as string,
req.user!.id,
req.ip
);
res.json({ data: result });
}
);
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
router.post(

View File

@@ -122,11 +122,12 @@ export const startUpgradeSchema = z.object({
});
export const setupRemoteTunnelSchema = z.object({
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
subdomainPrefix: z
.string()
.min(1)
.max(50)
.regex(/^[a-z0-9-]+$/, 'Prefix must be lowercase alphanumeric with hyphens')
.regex(/^[a-z0-9-]*$/, 'Prefix must be lowercase alphanumeric with hyphens')
.optional(),
});

View File

@@ -13,6 +13,18 @@ const exec = promisify(execCb);
const CA_VALIDITY_DAYS = 3650; // ~10 years
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
/**
* Shell/cert-injection guard. Slug flows into the OpenSSL -subj `/CN=...` string
* and into SAN DNS entries. An unvalidated slug like `foo/O=EvilOrg/CN=` would
* let callers forge arbitrary DN components. Added 2026-04-12.
*/
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
function assertSafeSlug(slug: string): void {
if (!SAFE_SLUG.test(slug)) {
throw new Error(`Invalid agent slug: must match ${SAFE_SLUG}`);
}
}
function computeFingerprint(certPem: string): string {
const der = Buffer.from(
certPem
@@ -91,6 +103,7 @@ export async function ensureCA() {
* Returns the certificate materials (plaintext) for one-time display.
*/
export async function issueAgentCert(instanceId: string, slug: string, agentUrl?: string) {
assertSafeSlug(slug);
const ca = await ensureCA();
const caKeyPem = decrypt(ca.encryptedKey);
@@ -115,6 +128,11 @@ export async function issueAgentCert(instanceId: string, slug: string, agentUrl?
if (agentUrl) {
try {
const hostname = new URL(agentUrl).hostname;
// Guard against SAN injection via crafted hostname (commas/newlines would
// inject extra SAN entries into the extfile). Added 2026-04-12.
if (/[,\n\r\0]/.test(hostname)) {
throw new Error('Invalid hostname');
}
// Detect IP vs DNS name
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {
sanEntries.push(`IP:${hostname}`);

View File

@@ -152,15 +152,22 @@ export async function checkInstanceHealth(instanceId: string) {
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.STOPPED },
data: {
status: InstanceStatus.STOPPED,
statusMessage: `No running containers detected at ${new Date().toISOString()}`,
},
});
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
const runningCount = containers.filter((c) => c.state === 'running').length;
await prisma.instance.update({
where: { id: instanceId },
data: { status: InstanceStatus.RUNNING },
data: {
status: InstanceStatus.RUNNING,
statusMessage: `${runningCount} container(s) running — detected at ${new Date().toISOString()}`,
},
});
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${runningCount} running containers detected)`);
}
// Sync domain and feature flags from .env if they have drifted

View File

@@ -44,6 +44,12 @@ export interface InstanceSecrets {
jwtAccessSecret: string;
jwtRefreshSecret: string;
jwtInviteSecret: string;
// Added 2026-04-12 (P2-2): Changemaker Lite now requires distinct secrets for
// Gitea SSO cookies and service-account password derivation — the old
// JWT_ACCESS_SECRET fallback was removed. New instances provisioned by CCP
// must receive both to boot.
giteaSsoSecret: string;
servicePasswordSalt: string;
encryptionKey: string;
initialAdminPassword: string;
nocodbAdminPassword: string;
@@ -69,6 +75,8 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm
jwtAccessSecret: randomHex(32),
jwtRefreshSecret: randomHex(32),
jwtInviteSecret: randomHex(32),
giteaSsoSecret: randomHex(32),
servicePasswordSalt: randomHex(32),
encryptionKey: randomHex(32),
initialAdminPassword: randomPassword(16),
nocodbAdminPassword: randomPassword(16),

View File

@@ -62,7 +62,8 @@ function getPangolinClient(): CcpPangolinClient {
}
function fullSubdomain(prefix: string, sub: string): string {
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
if (!prefix) return sub; // no prefix → use subdomain as-is (e.g., "app")
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
return `${prefix}-${sub}`; // e.g., "ck-app", "ck-api"
}
@@ -130,7 +131,10 @@ export async function setupTunnel(
throw new AppError(400, 'Tunnel is already configured. Use sync to update resources, or teardown first.', 'ALREADY_CONFIGURED');
}
const prefix = options.subdomainPrefix || instance.slug;
// Empty prefix means resources use standard subdomains (app., api., etc.)
// matching the instance's nginx config. A prefix like "ck" creates
// ck-app., ck-api., etc. for multi-tenant Pangolin domains.
const prefix = options.subdomainPrefix ?? '';
const driver = await getRemoteDriverForInstance({
id: instance.id,
@@ -318,7 +322,7 @@ export async function syncResources(
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
if (!instance.pangolinSiteId) throw new AppError(400, 'No tunnel configured', 'NO_TUNNEL');
const prefix = instance.pangolinSubdomainPrefix || instance.slug;
const prefix = instance.pangolinSubdomainPrefix ?? '';
const domain = await findDomainForInstance(client, instance.domain);
const existingResources = await client.listResources();
const siteId = instance.pangolinSiteId;
@@ -387,7 +391,26 @@ export async function teardownTunnel(
const siteId = instance.pangolinSiteId;
// Delete site from Pangolin (cascades resources + targets)
// Pangolin does NOT cascade-delete resources when a site is deleted.
// We must delete resources first to avoid orphaned entries.
try {
const allResources = await client.listResources();
const siteIdNum = Number(siteId);
for (const res of allResources) {
try {
const targets = await client.listTargets(String(res.resourceId));
const ours = targets.some((t) => Number(t.siteId) === siteIdNum);
if (ours) {
await client.deleteResource(String(res.resourceId));
logger.info(`[tunnel] ${instance.slug}: deleted resource ${res.name} (${res.fullDomain})`);
}
} catch { /* target lookup failed — skip */ }
}
} catch (err) {
logger.warn(`[tunnel] ${instance.slug}: resource cleanup failed: ${(err as Error).message}`);
}
// Now delete the site itself
try {
await client.deleteSite(siteId);
logger.info(`[tunnel] ${instance.slug}: deleted Pangolin site ${siteId}`);
@@ -549,6 +572,120 @@ export async function getTunnelStatus(instanceId: string): Promise<TunnelStatus>
};
}
// ─── Import existing tunnel ────────────────────────────────────────
/**
* Adopt a tunnel that was set up outside the CCP (e.g. by `config.sh --pangolin-site new`
* on the remote instance itself). Reads the remote `.env` via the agent, pulls the
* Pangolin vars, verifies the site exists in the CCP's Pangolin org, and persists the
* values on the Instance record so subsequent sync/teardown/status calls work.
*
* We DO NOT fetch the Newt secret from Pangolin — it's only in the remote .env, and
* Pangolin's API does not expose it after site creation. If the remote .env is missing
* it, the user has to teardown + setup via CCP instead.
*/
export async function importTunnel(
instanceId: string,
userId?: string,
ipAddress?: string | null
) {
const client = getPangolinClient();
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
if (!instance.isRemote) throw new AppError(400, 'Import only applies to remote instances', 'NOT_REMOTE');
if (instance.pangolinSiteId) {
throw new AppError(400, 'Tunnel already imported/configured — use sync or teardown instead', 'ALREADY_CONFIGURED');
}
// 1. Read the remote .env via mTLS agent
const driver = await getRemoteDriverForInstance({
id: instance.id,
slug: instance.slug,
isRemote: instance.isRemote,
agentUrl: instance.agentUrl,
});
const envVars = await driver.readEnvFile('');
if (!envVars) {
throw new AppError(502, 'Could not read .env from remote agent', 'AGENT_READ_FAILED');
}
// 2. Extract Pangolin vars
const siteId = envVars.PANGOLIN_SITE_ID?.trim();
const newtId = envVars.PANGOLIN_NEWT_ID?.trim();
const newtSecret = envVars.PANGOLIN_NEWT_SECRET?.trim();
const endpoint = envVars.PANGOLIN_ENDPOINT?.trim() || env.PANGOLIN_API_URL?.replace(/\/v1$/, '') || '';
const missing: string[] = [];
if (!siteId) missing.push('PANGOLIN_SITE_ID');
if (!newtId) missing.push('PANGOLIN_NEWT_ID');
if (!newtSecret) missing.push('PANGOLIN_NEWT_SECRET');
if (missing.length > 0) {
throw new AppError(
400,
`Remote .env missing Pangolin credentials: ${missing.join(', ')}. ` +
`Either the tunnel was never set up, or it was torn down. ` +
`Use "Setup Tunnel" to create a fresh one via CCP.`,
'REMOTE_ENV_MISSING_TUNNEL'
);
}
// 3. Verify the site exists in the CCP's Pangolin org (and that our API key can see it)
let onlineNow = false;
try {
const site = await client.getSite(siteId!);
onlineNow = site.online ?? false;
} catch (err) {
throw new AppError(
400,
`Site ${siteId} not found in CCP's Pangolin org (${env.PANGOLIN_ORG_ID}). ` +
`The remote instance may be using a different Pangolin org than the CCP is configured for. ` +
`Error: ${(err as Error).message}`,
'SITE_NOT_IN_CCP_ORG'
);
}
// 4. Persist on Instance record
await prisma.instance.update({
where: { id: instanceId },
data: {
pangolinEndpoint: endpoint || null,
pangolinSiteId: siteId,
pangolinNewtId: newtId,
pangolinNewtSecret: newtSecret,
// Note: we do NOT infer pangolinSubdomainPrefix — import assumes standard
// subdomains (app., api., etc.). If the user was using a prefix, they should
// teardown + setup via CCP instead.
},
});
// 5. Audit log
if (userId) {
await prisma.auditLog.create({
data: {
userId,
instanceId,
action: AuditAction.PANGOLIN_SETUP,
details: {
source: 'import',
siteId,
endpoint,
onlineAtImport: onlineNow,
} as unknown as Prisma.InputJsonValue,
ipAddress: ipAddress ?? null,
},
});
}
logger.info(`[tunnel] ${instance.slug}: imported existing tunnel (site ${siteId}, online=${onlineNow})`);
return {
imported: true,
siteId,
endpoint,
online: onlineNow,
};
}
// ─── .env Helpers ──────────────────────────────────────────────────
/**

View File

@@ -9,6 +9,29 @@ import { createEvent } from './event.service';
import { getRemoteDriverForInstance } from './execution-driver';
import type { AgentUpdateStatus } from './remote-driver';
/**
* Shell-injection guards. Any user- or DB-controlled value that flows into
* `bash`/`git` via `exec()` must be validated against these regexes first.
* Added 2026-04-12 after red-team audit found unvalidated `branch` and `basePath`
* values reaching the shell.
*/
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
const SAFE_PATH = /^\/[a-zA-Z0-9/_.-]{1,255}$/;
function assertSafeBranch(branch: string | null | undefined, ctx: string): void {
if (!branch) return;
if (!SAFE_BRANCH.test(branch)) {
throw new Error(`Invalid git branch name (${ctx}): must match ${SAFE_BRANCH}`);
}
}
function assertSafePath(p: string | null | undefined, ctx: string): void {
if (!p) return;
if (!SAFE_PATH.test(p)) {
throw new Error(`Invalid path (${ctx}): must match ${SAFE_PATH}`);
}
}
/**
* Write an INSTANCE_UPGRADE audit log entry capturing a terminal outcome.
* Wrapped in try/catch so that an audit-log DB failure cannot mask the
@@ -227,6 +250,10 @@ export async function startUpgrade(
}
}
// Guard against shell injection via branch name (flows into bash exec).
assertSafeBranch(options?.branch, 'options.branch');
assertSafeBranch(instance.gitBranch, 'instance.gitBranch');
assertSafePath(instance.basePath, 'instance.basePath');
const branch = options?.branch || instance.gitBranch;
// Create upgrade record

View File

@@ -26,7 +26,15 @@ JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}}
JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}}
JWT_INVITE_SECRET={{secrets.jwtInviteSecret}}
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Reduced 2026-04-12 from 7d → 24h (P2-3). Combined with device-fingerprint
# binding in the refresh JWT payload, this tightens the exploitation window
# for stolen refresh tokens.
JWT_REFRESH_EXPIRY=24h
# Gitea SSO cookie signing + service password salt — REQUIRED 2026-04-12 (P2-2).
# Distinct from JWT secrets; empty values will now fail Zod validation on boot.
GITEA_SSO_SECRET={{secrets.giteaSsoSecret}}
SERVICE_PASSWORD_SALT={{secrets.servicePasswordSalt}}
# Encryption
ENCRYPTION_KEY={{secrets.encryptionKey}}