mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
hardened OOBE bootstrap script, restore of bootstrap and unrestricted mode in sidebar if enabled
This commit is contained in:
@@ -1 +1 @@
|
||||
884
|
||||
885
|
||||
|
||||
@@ -379,7 +379,9 @@ public class AuthFunction
|
||||
isTenantAdmin,
|
||||
role,
|
||||
canManageBootstrapTokens,
|
||||
hasMcpAccess = mcpCheck.IsAllowed
|
||||
hasMcpAccess = mcpCheck.IsAllowed,
|
||||
bootstrapTokenEnabled = tenantConfig.BootstrapTokenEnabled,
|
||||
unrestrictedModeEnabled = tenantConfig.UnrestrictedModeEnabled
|
||||
}, needsAutoAdmin);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { validateBootstrapResponse } from "@/lib/bootstrapValidation";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const BACKEND_API_URL =
|
||||
process.env.BACKEND_API_URL ?? "https://autopilotmonitor-api.azurewebsites.net";
|
||||
|
||||
const AGENT_DOWNLOAD_URL =
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
interface ValidateResponse {
|
||||
success: boolean;
|
||||
tenantId: string;
|
||||
token: string;
|
||||
agentDownloadUrl: string;
|
||||
expiresAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ code: string }> },
|
||||
@@ -40,14 +30,24 @@ export async function GET(
|
||||
return errorScript(msg);
|
||||
}
|
||||
|
||||
const data = (await validateRes.json()) as ValidateResponse;
|
||||
const data: unknown = await validateRes.json();
|
||||
const result = validateBootstrapResponse(data);
|
||||
if (!result.ok) {
|
||||
// Log the failure category only — never the offending value, to keep
|
||||
// attack payloads out of Application Insights dashboards/exports.
|
||||
console.warn(
|
||||
`[go/${code}] bootstrap response validation failed: ${result.reason}`,
|
||||
);
|
||||
return errorScript(
|
||||
"Bootstrap response failed validation. Contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const script = generateOobeScript({
|
||||
tenantId: data.tenantId,
|
||||
bootstrapToken: data.token,
|
||||
agentDownloadUrl: data.agentDownloadUrl || AGENT_DOWNLOAD_URL,
|
||||
expiresAt: data.expiresAt,
|
||||
generatedAt: new Date().toISOString(),
|
||||
tenantId: result.value.tenantId,
|
||||
bootstrapToken: result.value.token,
|
||||
agentDownloadUrl: result.value.agentDownloadUrl,
|
||||
expiresAt: result.value.expiresAt,
|
||||
});
|
||||
|
||||
return new Response(script, {
|
||||
@@ -78,18 +78,24 @@ function errorScript(message: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
// SECURITY INVARIANT: All template parameters MUST come from
|
||||
// validateBootstrapResponse() in @/lib/bootstrapValidation. The single-quoted
|
||||
// PowerShell string literals below are the second line of defense against
|
||||
// injection (single-quoted PS strings do NOT expand $(...), $var, or
|
||||
// backticks). Do NOT change to double-quoted without re-reviewing the
|
||||
// validator's character classes end-to-end.
|
||||
function generateOobeScript(params: {
|
||||
tenantId: string;
|
||||
bootstrapToken: string;
|
||||
agentDownloadUrl: string;
|
||||
expiresAt: string;
|
||||
generatedAt: string;
|
||||
}): string {
|
||||
const generatedAt = new Date().toISOString();
|
||||
return `#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Autopilot Monitor OOBE Bootstrap
|
||||
Generated: ${params.generatedAt}
|
||||
Generated: ${generatedAt}
|
||||
Expires: ${params.expiresAt}
|
||||
|
||||
This script was generated by the Autopilot Monitor platform.
|
||||
@@ -99,7 +105,7 @@ function generateOobeScript(params: {
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$AgentDownloadUrl = "${params.agentDownloadUrl}"
|
||||
$AgentDownloadUrl = '${params.agentDownloadUrl}'
|
||||
$AgentBasePath = "$env:ProgramData\\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\\Logs"
|
||||
@@ -120,12 +126,12 @@ function Write-Log {
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log "Bootstrap token expires: ${params.expiresAt}"
|
||||
Write-Log 'Bootstrap token expires: ${params.expiresAt}'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath — skipping download"
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
@@ -147,7 +153,7 @@ try {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in $\{delay}s..."
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in \${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
@@ -164,7 +170,7 @@ try {
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token "${params.bootstrapToken}" --tenant-id "${params.tenantId}"
|
||||
& $agentExePath --install --no-auth --bootstrap-token '${params.bootstrapToken}' --tenant-id '${params.tenantId}'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { usePageSections } from "../../hooks/usePageSections";
|
||||
import { PageSectionItem } from "../../contexts/SidebarContext";
|
||||
import { useTenantConfig } from "./TenantConfigContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import {
|
||||
BuildingOfficeIcon, GearIcon, WrenchScrewdriverIcon, ChartBarIcon,
|
||||
} from "../../lib/sidebarIcons";
|
||||
|
||||
/**
|
||||
* Registers all tenant settings sections in the global sidebar
|
||||
* with group information for expandable rendering (GitHub-style).
|
||||
*/
|
||||
export function SettingsPageSections() {
|
||||
const { config, user } = useTenantConfig();
|
||||
const { user: authUser } = useAuth();
|
||||
|
||||
const items: PageSectionItem[] = useMemo(() => {
|
||||
const sections: PageSectionItem[] = [];
|
||||
const isAdmin = user?.isTenantAdmin || user?.isGlobalAdmin;
|
||||
|
||||
// 1. Tenant
|
||||
if (isAdmin) {
|
||||
sections.push(
|
||||
{ id: "access-management", label: "Access Management", href: "/settings/tenant/access-management", group: "Tenant", groupIcon: <BuildingOfficeIcon /> },
|
||||
{ id: "autopilot", label: "Autopilot Validation", href: "/settings/tenant/autopilot", group: "Tenant" },
|
||||
{ id: "hardware-whitelist", label: "Hardware Whitelist", href: "/settings/tenant/hardware-whitelist", group: "Tenant" },
|
||||
{ id: "notifications", label: "Notifications", href: "/settings/tenant/notifications", group: "Tenant" },
|
||||
);
|
||||
}
|
||||
if (config?.bootstrapTokenEnabled && (isAdmin || user?.canManageBootstrapTokens)) {
|
||||
sections.push(
|
||||
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", href: "/settings/tenant/bootstrap-sessions", group: "Tenant", ...(!isAdmin ? { groupIcon: <BuildingOfficeIcon /> } : {}) },
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Agent
|
||||
if (isAdmin) {
|
||||
sections.push(
|
||||
{ id: "settings", label: "Agent Settings", href: "/settings/agent/settings", group: "Agent", groupIcon: <GearIcon /> },
|
||||
{ id: "analyzers", label: "Agent Analyzers", href: "/settings/agent/analyzers", group: "Agent" },
|
||||
{ id: "diagnostics", label: "Diagnostics Package", href: "/settings/agent/diagnostics", group: "Agent" },
|
||||
);
|
||||
if (config?.unrestrictedModeEnabled) {
|
||||
sections.push(
|
||||
{ id: "unrestricted-mode", label: "Unrestricted Mode", href: "/settings/agent/unrestricted-mode", group: "Agent" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Maintenance
|
||||
if (isAdmin) {
|
||||
sections.push(
|
||||
{ id: "data", label: "Data Management", href: "/settings/management/data", group: "Maintenance", groupIcon: <WrenchScrewdriverIcon /> },
|
||||
{ id: "offboarding", label: "Offboarding", href: "/settings/management/offboarding", group: "Maintenance" },
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Reporting (only visible if user has MCP access)
|
||||
if (authUser?.hasMcpAccess) {
|
||||
sections.push(
|
||||
{ id: "mcp-usage", label: "MCP Usage", href: "/settings/reporting/mcp-usage", group: "Reporting", groupIcon: <ChartBarIcon /> },
|
||||
);
|
||||
}
|
||||
|
||||
return sections;
|
||||
}, [user, config?.unrestrictedModeEnabled, config?.bootstrapTokenEnabled, authUser?.hasMcpAccess]);
|
||||
|
||||
usePageSections(items, "Configuration", "route");
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -189,19 +189,39 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
|
||||
|
||||
// Visible expandable groups (with item-level filtering for feature-gated items)
|
||||
const hasMcpAccess = user?.hasMcpAccess ?? false;
|
||||
const isAdminLike = isTenantAdmin || isGlobalAdmin;
|
||||
const canManageBootstrapTokens = user?.canManageBootstrapTokens ?? false;
|
||||
const bootstrapTokenEnabled = user?.bootstrapTokenEnabled ?? false;
|
||||
const unrestrictedModeEnabled = user?.unrestrictedModeEnabled ?? false;
|
||||
const visibleExpandableGroups = useMemo(() => {
|
||||
return EXPANDABLE_NAV_GROUPS
|
||||
.filter(isGroupVisible)
|
||||
.map((group) => {
|
||||
// Filter out MCP item if user doesn't have MCP access
|
||||
const filteredItems = group.items.filter((item) => {
|
||||
if (item.id === "cfg-reporting") return hasMcpAccess;
|
||||
return true;
|
||||
});
|
||||
const filteredItems = group.items
|
||||
.filter((item) => {
|
||||
if (item.id === "cfg-reporting") return hasMcpAccess;
|
||||
return true;
|
||||
})
|
||||
.map((item) => {
|
||||
// Sub-item gating for feature-flagged entries (tenant feature flags)
|
||||
const filteredSubs = item.items.filter((sub) => {
|
||||
if (sub.id === "cfg-bootstrap-sessions") {
|
||||
return bootstrapTokenEnabled && (isAdminLike || canManageBootstrapTokens);
|
||||
}
|
||||
if (sub.id === "cfg-agent-unrestricted") {
|
||||
return isAdminLike && unrestrictedModeEnabled;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...item, items: filteredSubs };
|
||||
})
|
||||
// Drop items whose sub-items have all been filtered out
|
||||
.filter((item) => item.items.length > 0);
|
||||
return { ...group, items: filteredItems };
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAdminOrOperator, isGlobalAdmin, globalAdminMode, hasMcpAccess]);
|
||||
}, [isAdminOrOperator, isGlobalAdmin, globalAdminMode, hasMcpAccess, isAdminLike, canManageBootstrapTokens, bootstrapTokenEnabled, unrestrictedModeEnabled]);
|
||||
|
||||
// Auto-expand the group containing the current pathname
|
||||
useEffect(() => {
|
||||
|
||||
@@ -97,6 +97,8 @@ interface UserInfo {
|
||||
role: 'Admin' | 'Operator' | 'Viewer' | null;
|
||||
canManageBootstrapTokens: boolean;
|
||||
hasMcpAccess: boolean;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -154,6 +156,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
|
||||
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
|
||||
hasMcpAccess: (data.hasMcpAccess as boolean) || false,
|
||||
bootstrapTokenEnabled: (data.bootstrapTokenEnabled as boolean) || false,
|
||||
unrestrictedModeEnabled: (data.unrestrictedModeEnabled as boolean) || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,6 +209,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
role: null,
|
||||
canManageBootstrapTokens: false,
|
||||
hasMcpAccess: false,
|
||||
bootstrapTokenEnabled: false,
|
||||
unrestrictedModeEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -223,6 +229,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
role: data.role || null,
|
||||
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
|
||||
hasMcpAccess: data.hasMcpAccess || false,
|
||||
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
|
||||
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
|
||||
};
|
||||
} catch (error) {
|
||||
// If the refresh token is expired or consent is required, redirect to
|
||||
@@ -253,6 +261,8 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
role: null,
|
||||
canManageBootstrapTokens: false,
|
||||
hasMcpAccess: false,
|
||||
bootstrapTokenEnabled: false,
|
||||
unrestrictedModeEnabled: false,
|
||||
};
|
||||
}
|
||||
}, [instance]);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { validateBootstrapResponse } from "../bootstrapValidation";
|
||||
|
||||
const VALID_TENANT_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
const VALID_TOKEN = "11111111-2222-3333-4444-555555555555";
|
||||
const VALID_URL =
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip";
|
||||
|
||||
// Fixed system time so expiresAt math is deterministic
|
||||
const FIXED_NOW = new Date("2026-04-08T12:00:00.000Z").getTime();
|
||||
|
||||
function inFuture(ms: number): string {
|
||||
return new Date(FIXED_NOW + ms).toISOString();
|
||||
}
|
||||
|
||||
function validResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
success: true,
|
||||
tenantId: VALID_TENANT_ID,
|
||||
token: VALID_TOKEN,
|
||||
agentDownloadUrl: VALID_URL,
|
||||
expiresAt: inFuture(60 * 60 * 1000), // 1 hour
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateBootstrapResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ---------- Happy paths ----------
|
||||
|
||||
describe("happy path", () => {
|
||||
it("accepts a realistic backend response", () => {
|
||||
const result = validateBootstrapResponse(validResponse());
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.tenantId).toBe(VALID_TENANT_ID);
|
||||
expect(result.value.token).toBe(VALID_TOKEN);
|
||||
expect(result.value.agentDownloadUrl).toBe(VALID_URL);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts STJ 'O' format with 7 fractional digits", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ expiresAt: "2026-04-08T13:34:56.7891234Z" }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts whole-second timestamp without fractional digits", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ expiresAt: "2026-04-08T13:34:56Z" }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts numeric timezone offset", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ expiresAt: "2026-04-08T15:34:56+02:00" }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Injection payloads (the actual vuln) ----------
|
||||
|
||||
describe("injection payloads", () => {
|
||||
it("rejects token with PS double-quote escape", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ token: 'x"; Start-Process calc.exe #' }),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "token" });
|
||||
});
|
||||
|
||||
it("rejects tenantId with PS subshell expansion", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ tenantId: "$(calc)" }),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "tenantId" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl on a foreign host", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ agentDownloadUrl: "https://evil.com/agent/x.zip" }),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with $ in pathname (PS subshell)", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/$(calc).zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with backtick in pathname", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/`whoami`.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with query string (smuggling)", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/x.zip?sas=evil",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with hash fragment", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/x.zip#frag",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with trailing-dot hostname", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net./agent/x.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with embedded credentials", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://user:pass@autopilotmonitor.blob.core.windows.net/agent/x.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with non-standard port", () => {
|
||||
// Note: :443 is normalized to "" by Node's URL parser (default port),
|
||||
// so it can't be blocked and represents no attack surface. Non-default
|
||||
// ports like :8443 do remain in url.port and are correctly rejected.
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net:8443/agent/x.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl over http (not https)", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"http://autopilotmonitor.blob.core.windows.net/agent/x.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with leading-dot filename", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/.hidden.zip",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl with non-zip extension", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl:
|
||||
"https://autopilotmonitor.blob.core.windows.net/agent/x.exe",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Shape rejection ----------
|
||||
|
||||
describe("shape", () => {
|
||||
it("rejects null", () => {
|
||||
expect(validateBootstrapResponse(null)).toEqual({ ok: false, reason: "shape" });
|
||||
});
|
||||
|
||||
it("rejects array", () => {
|
||||
expect(validateBootstrapResponse([])).toEqual({ ok: false, reason: "shape" });
|
||||
});
|
||||
|
||||
it("rejects string primitive", () => {
|
||||
expect(validateBootstrapResponse("string")).toEqual({
|
||||
ok: false,
|
||||
reason: "shape",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects number primitive", () => {
|
||||
expect(validateBootstrapResponse(42)).toEqual({ ok: false, reason: "shape" });
|
||||
});
|
||||
|
||||
it("rejects empty object (success missing)", () => {
|
||||
expect(validateBootstrapResponse({})).toEqual({
|
||||
ok: false,
|
||||
reason: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects prototype-pollution probe (fields on __proto__ only)", () => {
|
||||
const probe = Object.create({
|
||||
success: true,
|
||||
tenantId: VALID_TENANT_ID,
|
||||
token: VALID_TOKEN,
|
||||
agentDownloadUrl: VALID_URL,
|
||||
expiresAt: inFuture(60 * 60 * 1000),
|
||||
});
|
||||
expect(validateBootstrapResponse(probe)).toEqual({
|
||||
ok: false,
|
||||
reason: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- success coercion ----------
|
||||
|
||||
describe("success field", () => {
|
||||
it("rejects success: false", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ success: false })),
|
||||
).toEqual({ ok: false, reason: "success" });
|
||||
});
|
||||
|
||||
it("rejects success: 1 (truthy number)", () => {
|
||||
expect(validateBootstrapResponse(validResponse({ success: 1 }))).toEqual({
|
||||
ok: false,
|
||||
reason: "success",
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects success: "true" (string)', () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ success: "true" })),
|
||||
).toEqual({ ok: false, reason: "success" });
|
||||
});
|
||||
|
||||
it("rejects missing success", () => {
|
||||
const { success: _omit, ...rest } = validResponse();
|
||||
void _omit;
|
||||
expect(validateBootstrapResponse(rest)).toEqual({
|
||||
ok: false,
|
||||
reason: "success",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Control chars / non-ASCII ----------
|
||||
|
||||
describe("control chars and non-ASCII", () => {
|
||||
it("rejects tenantId containing null byte", () => {
|
||||
// Pad to length 36 so we hit ASCII filter, not length check
|
||||
const padded = "\u0000" + VALID_TENANT_ID.slice(1);
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ tenantId: padded })),
|
||||
).toEqual({ ok: false, reason: "tenantId" });
|
||||
});
|
||||
|
||||
it("rejects token containing newline", () => {
|
||||
const padded = "\n" + VALID_TOKEN.slice(1);
|
||||
expect(validateBootstrapResponse(validResponse({ token: padded }))).toEqual({
|
||||
ok: false,
|
||||
reason: "token",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects expiresAt with fullwidth digits (non-ASCII)", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({ expiresAt: "2026-04-08T13:34:56\uFF10" }),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- Length and field-type bounds ----------
|
||||
|
||||
describe("length and type bounds", () => {
|
||||
it("rejects 35-char tenantId", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({ tenantId: VALID_TENANT_ID.slice(0, 35) }),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "tenantId" });
|
||||
});
|
||||
|
||||
it("rejects empty token", () => {
|
||||
expect(validateBootstrapResponse(validResponse({ token: "" }))).toEqual({
|
||||
ok: false,
|
||||
reason: "token",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects agentDownloadUrl over 256 chars", () => {
|
||||
const longPath = "x".repeat(260);
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({
|
||||
agentDownloadUrl: `https://autopilotmonitor.blob.core.windows.net/agent/${longPath}.zip`,
|
||||
}),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "agentDownloadUrl" });
|
||||
});
|
||||
|
||||
it("rejects tenantId of wrong type (number)", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ tenantId: 12345 })),
|
||||
).toEqual({ ok: false, reason: "tenantId" });
|
||||
});
|
||||
|
||||
it("rejects token of wrong type (null)", () => {
|
||||
expect(validateBootstrapResponse(validResponse({ token: null }))).toEqual({
|
||||
ok: false,
|
||||
reason: "token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- expiresAt time bounds ----------
|
||||
|
||||
describe("expiresAt time bounds", () => {
|
||||
it("rejects expired (in the past)", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({ expiresAt: inFuture(-60 * 1000) }),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
|
||||
it("rejects exactly now", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ expiresAt: inFuture(0) })),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
|
||||
it("rejects more than 14 days in the future", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({ expiresAt: inFuture(15 * 24 * 60 * 60 * 1000) }),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
|
||||
it("accepts 7 days in the future (within backend cap)", () => {
|
||||
const result = validateBootstrapResponse(
|
||||
validResponse({ expiresAt: inFuture(7 * 24 * 60 * 60 * 1000) }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects malformed string 'not-a-date'", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ expiresAt: "not-a-date" })),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
|
||||
it("rejects date-only string (no time component)", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(validResponse({ expiresAt: "2026-04-08" })),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
|
||||
it("rejects timestamp without timezone", () => {
|
||||
expect(
|
||||
validateBootstrapResponse(
|
||||
validResponse({ expiresAt: "2026-04-08T13:34:56" }),
|
||||
),
|
||||
).toEqual({ ok: false, reason: "expiresAt" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Bootstrap response validator.
|
||||
*
|
||||
* SECURITY-CRITICAL: All validated values are interpolated into a PowerShell
|
||||
* script that runs as SYSTEM during Windows OOBE (see app/go/[code]/route.ts).
|
||||
* Every character class below was chosen to block PS metacharacters
|
||||
* ($, backticks, ", ', #>, newline) so that even if the second-line defense
|
||||
* (single-quoted PS string literals) is accidentally loosened, the validator
|
||||
* still prevents injection. Do NOT relax any rule without re-reviewing
|
||||
* route.ts end-to-end.
|
||||
*/
|
||||
|
||||
import { isGuid } from "./inputValidation";
|
||||
|
||||
export interface ValidatedBootstrapResponse {
|
||||
tenantId: string;
|
||||
token: string;
|
||||
agentDownloadUrl: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export type BootstrapValidationFailure =
|
||||
| "shape"
|
||||
| "success"
|
||||
| "tenantId"
|
||||
| "token"
|
||||
| "agentDownloadUrl"
|
||||
| "expiresAt";
|
||||
|
||||
export type BootstrapValidationResult =
|
||||
| { ok: true; value: ValidatedBootstrapResponse }
|
||||
| { ok: false; reason: BootstrapValidationFailure };
|
||||
|
||||
const PRINTABLE_ASCII_RE = /^[\x20-\x7E]+$/;
|
||||
|
||||
const ISO_8601_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
const AGENT_PATH_RE = /^\/agent\/[A-Za-z0-9_-][A-Za-z0-9._-]{0,79}\.zip$/;
|
||||
|
||||
const AGENT_HOSTNAME = "autopilotmonitor.blob.core.windows.net";
|
||||
|
||||
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function fail(reason: BootstrapValidationFailure): BootstrapValidationResult {
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
function isPrintableAsciiString(value: unknown, maxLength: number): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= maxLength &&
|
||||
PRINTABLE_ASCII_RE.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function readField(data: Record<string, unknown>, key: string): unknown {
|
||||
return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : undefined;
|
||||
}
|
||||
|
||||
function isValidAgentDownloadUrl(value: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (url.protocol !== "https:") return false;
|
||||
if (url.hostname !== AGENT_HOSTNAME) return false;
|
||||
if (url.username !== "" || url.password !== "") return false;
|
||||
if (url.port !== "") return false;
|
||||
if (url.search !== "" || url.hash !== "") return false;
|
||||
if (!AGENT_PATH_RE.test(url.pathname)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidExpiresAt(value: string): boolean {
|
||||
if (!ISO_8601_RE.test(value)) return false;
|
||||
const parsed = new Date(value).getTime();
|
||||
if (!Number.isFinite(parsed)) return false;
|
||||
const now = Date.now();
|
||||
if (parsed <= now) return false;
|
||||
if (parsed > now + FOURTEEN_DAYS_MS) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function validateBootstrapResponse(
|
||||
data: unknown,
|
||||
): BootstrapValidationResult {
|
||||
// Shape: must be a plain object (not null, not array)
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return fail("shape");
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
// success === true (strict — no truthy coercion)
|
||||
if (readField(obj, "success") !== true) {
|
||||
return fail("success");
|
||||
}
|
||||
|
||||
// Field presence + ASCII + length bounds
|
||||
const tenantId = readField(obj, "tenantId");
|
||||
if (!isPrintableAsciiString(tenantId, 36) || tenantId.length !== 36) {
|
||||
return fail("tenantId");
|
||||
}
|
||||
if (!isGuid(tenantId)) {
|
||||
return fail("tenantId");
|
||||
}
|
||||
|
||||
const token = readField(obj, "token");
|
||||
if (!isPrintableAsciiString(token, 36) || token.length !== 36) {
|
||||
return fail("token");
|
||||
}
|
||||
if (!isGuid(token)) {
|
||||
return fail("token");
|
||||
}
|
||||
|
||||
const agentDownloadUrl = readField(obj, "agentDownloadUrl");
|
||||
if (!isPrintableAsciiString(agentDownloadUrl, 256)) {
|
||||
return fail("agentDownloadUrl");
|
||||
}
|
||||
if (!isValidAgentDownloadUrl(agentDownloadUrl)) {
|
||||
return fail("agentDownloadUrl");
|
||||
}
|
||||
|
||||
const expiresAt = readField(obj, "expiresAt");
|
||||
if (!isPrintableAsciiString(expiresAt, 40)) {
|
||||
return fail("expiresAt");
|
||||
}
|
||||
if (!isValidExpiresAt(expiresAt)) {
|
||||
return fail("expiresAt");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
tenantId,
|
||||
token,
|
||||
agentDownloadUrl,
|
||||
expiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
|
||||
{ id: "cfg-autopilot", label: "Autopilot Validation", href: "/settings/tenant/autopilot" },
|
||||
{ id: "cfg-hardware", label: "Hardware Whitelist", href: "/settings/tenant/hardware-whitelist" },
|
||||
{ id: "cfg-notifications", label: "Notifications", href: "/settings/tenant/notifications" },
|
||||
{ id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: "/settings/tenant/bootstrap-sessions" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -188,6 +189,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
|
||||
{ id: "cfg-agent-settings", label: "Agent Settings", href: "/settings/agent/settings" },
|
||||
{ id: "cfg-agent-analyzers", label: "Agent Analyzers", href: "/settings/agent/analyzers" },
|
||||
{ id: "cfg-diagnostics", label: "Diagnostics Package", href: "/settings/agent/diagnostics" },
|
||||
{ id: "cfg-agent-unrestricted", label: "Unrestricted Mode", href: "/settings/agent/unrestricted-mode" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user