From 8daf7c287222c665e7a5b9cca17cec37594e29c8 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 7 Jun 2026 12:07:08 -0700 Subject: [PATCH] Rename and add browser target update --- server/lib/blueprints/applyBlueprint.ts | 48 +++++-- ...clientResources.ts => privateResources.ts} | 2 +- .../{proxyResources.ts => publicResources.ts} | 8 +- src/app/rdp/RdpClient.tsx | 56 ++++---- src/app/ssh/SshClient.tsx | 53 +++++--- src/app/vnc/VncClient.tsx | 44 ++++--- src/lib/secureLocalStorage.ts | 124 ++++++++++++++++++ 7 files changed, 259 insertions(+), 76 deletions(-) rename server/lib/blueprints/{clientResources.ts => privateResources.ts} (99%) rename server/lib/blueprints/{proxyResources.ts => publicResources.ts} (99%) create mode 100644 src/lib/secureLocalStorage.ts diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 5296bb4d2..f2bb9b0c8 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -10,16 +10,22 @@ import { clientSiteResources } from "@server/db"; import { Config, ConfigSchema } from "./types"; -import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; +import { + PublicResourcesResults, + updatePublicResources +} from "./publicResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sites } from "@server/db"; import { eq, and, isNotNull } from "drizzle-orm"; -import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; +import { + addTargets as addProxyTargets, + sendBrowserGatewayTargets +} from "@server/routers/newt/targets"; import { ClientResourcesResults, - updateClientResources -} from "./clientResources"; + updatePrivateResources +} from "./privateResources"; import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; @@ -54,18 +60,18 @@ export async function applyBlueprint({ let error: any | null = null; try { - let proxyResourcesResults: ProxyResourcesResults = []; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); - proxyResourcesResults = await updateProxyResources( + proxyResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updateClientResources( + clientResourcesResults = await updatePrivateResources( orgId, config, trx, @@ -104,13 +110,27 @@ export async function applyBlueprint({ (hc) => hc.targetId === target.targetId ); - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck ? [matchingHealthcheck] : [], - result.proxyResource.mode === "udp" ? "udp" : "tcp", - site.newt.version - ); + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); + } } } } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/privateResources.ts similarity index 99% rename from server/lib/blueprints/clientResources.ts rename to server/lib/blueprints/privateResources.ts index 34e668984..3e6a784e0 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -105,7 +105,7 @@ export type ClientResourcesResults = { oldSites: { siteId: number }[]; }[]; -export async function updateClientResources( +export async function updatePrivateResources( orgId: string, config: Config, trx: Transaction, diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/publicResources.ts similarity index 99% rename from server/lib/blueprints/proxyResources.ts rename to server/lib/blueprints/publicResources.ts index b17878974..2bc1a6d7f 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -52,19 +52,19 @@ import { encrypt } from "@server/lib/crypto"; import { generateId } from "@server/auth/sessions/app"; import serverConfig from "@server/lib/config"; -export type ProxyResourcesResults = { +export type PublicResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; healthchecksToUpdate: TargetHealthCheck[]; }[]; -export async function updateProxyResources( +export async function updatePublicResources( orgId: string, config: Config, trx: Transaction, siteId?: number -): Promise { - const results: ProxyResourcesResults = []; +): Promise { + const results: PublicResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index f5ad0dc1d..4dd90242f 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { useTranslations } from "next-intl"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; declare module "react" { namespace JSX { @@ -63,22 +67,14 @@ type RdpCredentialsForm = { enableClipboard: boolean; }; -function loadStoredCredentials(key: string): RdpCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as RdpCredentialsForm; - } catch { - // ignore - } - return { - username: "", - password: "", - domain: "", - kdcProxyUrl: "", - pcb: "", - enableClipboard: true - }; -} +const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = { + username: "", + password: "", + domain: "", + kdcProxyUrl: "", + pcb: "", + enableClipboard: true +}; const isIronError = (error: unknown): error is IronError => { return ( @@ -113,9 +109,25 @@ export default function RdpClient({ const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_RDP_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); @@ -293,11 +305,11 @@ export default function RdpClient({ try { const sessionInfo = await userInteraction.connect(builder.build()); - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnecting(false); setShowLogin(false); userInteraction.setVisibility(true); diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 8d97b970b..932b6336b 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -32,6 +32,10 @@ import { useTranslations } from "next-intl"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; +import { + loadEncryptedLocalStorage, + saveEncryptedLocalStorage +} from "@app/lib/secureLocalStorage"; type AuthTab = "password" | "privateKey"; @@ -48,15 +52,11 @@ type ConnectCredentials = { certificate?: string; }; -function loadStoredCredentials(key: string): SshCredentialsForm { - try { - const saved = localStorage.getItem(key); - if (saved) return JSON.parse(saved) as SshCredentialsForm; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; -} +const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = { + username: "", + password: "", + privateKey: "" +}; export default function SshClient({ target, @@ -86,9 +86,25 @@ export default function SshClient({ }); const form = useForm({ - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_SSH_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -252,14 +268,11 @@ export default function SshClient({ }) ); if (!override) { - try { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify(form.getValues()) - ); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + form.getValues(), + target.authToken + ); } }; @@ -625,7 +638,7 @@ export default function SshClient({ {connected && (
-
+ {/*
-
+
*/}
({ resolver: zodResolver(formSchema), - defaultValues: loadStoredCredentials(STORAGE_KEY) + defaultValues: DEFAULT_VNC_CREDENTIALS }); + useEffect(() => { + let cancelled = false; + + void loadEncryptedLocalStorage( + STORAGE_KEY, + target?.authToken + ).then((saved) => { + if (cancelled || !saved) return; + form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved }); + }); + + return () => { + cancelled = true; + }; + }, [form, target?.authToken]); + const [connected, setConnected] = useState(false); const [connectError, setConnectError] = useState(null); const rfbRef = useRef(null); @@ -132,11 +146,11 @@ export default function VncClient({ rfb.resizeSession = true; rfb.addEventListener("connect", () => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); - } catch { - // ignore - } + void saveEncryptedLocalStorage( + STORAGE_KEY, + values, + target.authToken + ); setConnected(true); }); diff --git a/src/lib/secureLocalStorage.ts b/src/lib/secureLocalStorage.ts new file mode 100644 index 000000000..c8ceb601a --- /dev/null +++ b/src/lib/secureLocalStorage.ts @@ -0,0 +1,124 @@ +type EncryptedStorageEnvelope = { + v: 1; + s: string; + i: string; + d: string; +}; + +const PBKDF2_ITERATIONS = 120000; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function deriveKey(authToken: string, salt: ArrayBuffer) { + const subtle = window.crypto?.subtle; + if (!subtle) { + throw new Error("Web Crypto is unavailable"); + } + + const tokenKey = await subtle.importKey( + "raw", + toArrayBuffer(new TextEncoder().encode(authToken)), + "PBKDF2", + false, + ["deriveKey"] + ); + + return subtle.deriveKey( + { + name: "PBKDF2", + salt, + iterations: PBKDF2_ITERATIONS, + hash: "SHA-256" + }, + tokenKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +export async function saveEncryptedLocalStorage( + storageKey: string, + value: T, + authToken: string | null | undefined +) { + if (typeof window === "undefined") return; + if (!authToken) { + window.localStorage.removeItem(storageKey); + return; + } + + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const encrypted = await window.crypto.subtle.encrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(plaintext) + ); + + const payload: EncryptedStorageEnvelope = { + v: 1, + s: bytesToBase64(salt), + i: bytesToBase64(iv), + d: bytesToBase64(new Uint8Array(encrypted)) + }; + + window.localStorage.setItem(storageKey, JSON.stringify(payload)); +} + +export async function loadEncryptedLocalStorage( + storageKey: string, + authToken: string | null | undefined +): Promise { + if (typeof window === "undefined") return null; + if (!authToken) return null; + + const raw = window.localStorage.getItem(storageKey); + if (!raw) return null; + + try { + const payload = JSON.parse(raw) as EncryptedStorageEnvelope; + if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) { + throw new Error("Invalid encrypted payload"); + } + + const salt = base64ToBytes(payload.s); + const iv = base64ToBytes(payload.i); + const data = base64ToBytes(payload.d); + const key = await deriveKey(authToken, toArrayBuffer(salt)); + const decrypted = await window.crypto.subtle.decrypt( + { name: "AES-GCM", iv: toArrayBuffer(iv) }, + key, + toArrayBuffer(data) + ); + const json = new TextDecoder().decode(decrypted); + return JSON.parse(json) as T; + } catch { + window.localStorage.removeItem(storageKey); + return null; + } +}