* feat: add an auto or fixed columns picker to card grids

* feat: add safe mode that only runs layout and theme plugins

* docs: add simple layout to plugin table

* fix: ask for local network access in the macOS app

* feat: allow plugin registries on listed private hosts (#1705)

* chore: add private registry hosts to the changelog

* feat: use the proxy for plugin registry downloads

* fix: allow changing connection origin on shared hosts

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.5

---------

Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
Luke GustafsonandLukas authored and GitHub committed 2026-10-22 13:30:48 -05:00
1 parent 7b7b17e31f
commit 96b8520fb4
96 files changed
+1885 -474

No files matched your search

+65 -3
View File
@@ -12,6 +12,7 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { isIP } from "node:net";
import path from "node:path";
import semver from "semver";
import {
@@ -23,7 +24,11 @@ import {
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import {
isBlockedAddress,
safeOutboundFetch,
} from "../utils/safe-outbound-fetch.js";
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
import { pluginLogger } from "../utils/logger.js";
import {
COMMUNITY_PLUGIN_KEYS,
@@ -278,15 +283,72 @@ export function canRunRelease(
);
}
/**
* Hosts the admin lets registry fetches reach on a private address, like a
* registry in the LAN. Matched by host name on any port, also when another
* index or a redirect points there; every other host stays blocked.
*/
export function getRegistryPrivateHosts(): string[] {
return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map(normalizePrivateHost)
.filter((host): host is string => host !== undefined);
}
/**
* The host in the form safeOutboundFetch compares against, or undefined when
* the entry carries more than a host, like a scheme, port, path or user.
*/
function normalizePrivateHost(entry: string): string | undefined {
const candidate = isIP(entry) === 6 ? `[${entry}]` : entry;
if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined;
try {
return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, "");
} catch {
return undefined;
}
}
/**
* With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy,
* which does its own name lookup. Only the URL itself is checked here: web
* pages only, no logins in it, and no private address typed in as a literal.
*/
function fetchThroughProxy(
url: string,
options: RequestInit,
privateHosts: readonly string[],
): Promise<Response> {
const parsed = new URL(url);
const host = parsed.hostname.replace(/^\[|\]$/g, "");
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password
) {
throw new Error("Invalid outbound URL");
}
if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) {
throw new Error("Private destinations are not allowed");
}
return fetchWithProxy(url, options);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
const privateHosts = getRegistryPrivateHosts();
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const response = await safeOutboundFetch(current, {
const options: RequestInit = {
method: "GET",
redirect: "manual",
headers: { "User-Agent": "Termix" },
});
};
const response = getProxyAgent(current)
? await fetchThroughProxy(current, options, privateHosts)
: await safeOutboundFetch(current, options, privateHosts);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`);