feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
/**
* Whether a user signs in through an external login (SSO, LDAP or any login
* plugin) rather than only a password. Read from the is_oidc column, which
* keeps its 2.8 name until 3.0.0 replaces it with user_external_identities.
*/
export function isExternalAccount(user: {
isOidc?: boolean | number | null;
}): boolean {
return !!user.isOidc;
}
+1 -1
View File
@@ -177,7 +177,7 @@ async function unlockUser(
if (identity.kind === "external" || user.isOidc) {
try {
await authManager.authenticateOIDCUser(user.id, deviceType);
await authManager.authenticateExternalUser(user.id, deviceType);
} catch (error) {
authLogger.error("Failed to set up external user encryption", error, {
operation: "external_user_encryption_setup_failed",
+4 -2
View File
@@ -167,7 +167,9 @@ async function findLinkedUser(
// not scoped to a provider, so only an SSO account that has no link yet,
// from the same provider when both say which, can be claimed this way.
if (identity.legacyIdentifier) {
const legacy = await users.findByOidcIdentifier(identity.legacyIdentifier);
const legacy = await users.findByExternalIdentifier(
identity.legacyIdentifier,
);
if (legacy && (await mayClaimLegacyAccount(legacy, identity))) {
await identities.linkIdentity({
userId: legacy.id,
@@ -258,7 +260,7 @@ async function createUser(
deviceType === "desktop" || deviceType === "mobile"
? THIRTY_DAYS_MS
: ONE_DAY_MS;
await AuthManager.getInstance().registerOIDCUser(id, sessionDurationMs);
await AuthManager.getInstance().registerExternalUser(id, sessionDurationMs);
} catch (encryptionError) {
await users.delete(id);
authLogger.error(
+3
View File
@@ -4,6 +4,7 @@
* they all behave the same way.
*/
import { isExternalAccount } from "./external-account.js";
import type { Request, Response } from "express";
import { AuthManager } from "../utils/auth-manager.js";
import { authLogger } from "../utils/logger.js";
@@ -139,6 +140,8 @@ export async function issueSession(
is_admin: !!user.isAdmin,
username: user.username,
userId: user.id,
is_external: isExternalAccount(user),
// 2.8 name, kept until 3.0.0.
is_oidc: !!user.isOidc,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
+2 -2
View File
@@ -625,7 +625,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateOIDCUser(
const oidcUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
@@ -1058,7 +1058,7 @@ app.post(
if (!DataCrypto.getUserDataKey(userId)) {
if (isOidcUser) {
const oidcUnlocked = await authManager.authenticateOIDCUser(
const oidcUnlocked = await authManager.authenticateExternalUser(
userId,
deviceInfo.type,
);
@@ -1,98 +0,0 @@
import { and, eq } from "drizzle-orm";
import { pluginInstallCounts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
export interface PluginInstallCountUpsertInput {
pluginId: string;
registryId: string;
count: number;
source?: string;
}
export class PluginInstallCountRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByPlugin(pluginId: string): Promise<PluginInstallCountRecord[]> {
return this.context.drizzle
.select()
.from(pluginInstallCounts)
.where(eq(pluginInstallCounts.pluginId, pluginId));
}
async findByPluginAndRegistry(
pluginId: string,
registryId: string,
): Promise<PluginInstallCountRecord | null> {
const rows = await this.context.drizzle
.select()
.from(pluginInstallCounts)
.where(
and(
eq(pluginInstallCounts.pluginId, pluginId),
eq(pluginInstallCounts.registryId, registryId),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsert(
input: PluginInstallCountUpsertInput,
now = new Date().toISOString(),
): Promise<PluginInstallCountRecord> {
const existing = await this.findByPluginAndRegistry(
input.pluginId,
input.registryId,
);
if (existing) {
const [updated] = await updateReturning(
this.context,
pluginInstallCounts,
{
count: input.count,
source: input.source ?? existing.source,
updatedAt: now,
},
eq(pluginInstallCounts.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(this.context, pluginInstallCounts, {
pluginId: input.pluginId,
registryId: input.registryId,
count: input.count,
source: input.source ?? "aggregate-telemetry",
updatedAt: now,
});
await this.afterWrite();
return created;
}
async deleteByPlugin(pluginId: string): Promise<number> {
const result = await this.context.drizzle
.delete(pluginInstallCounts)
.where(eq(pluginInstallCounts.pluginId, pluginId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,129 +0,0 @@
import { eq } from "drizzle-orm";
import { pluginRegistries } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import {
deleteReturning,
insertReturning,
updateReturning,
} from "./returning.js";
export type PluginRegistryRecord = typeof pluginRegistries.$inferSelect;
export interface PluginRegistryCreateInput {
id: string;
name: string;
url: string;
kind?: string;
enabled?: boolean;
signingKey?: string | null;
}
export interface PluginRegistryUpdateInput {
name?: string;
url?: string;
kind?: string;
enabled?: boolean;
signingKey?: string | null;
lastCheckedAt?: string | null;
lastIndexHash?: string | null;
}
export class PluginRegistryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listAll(): Promise<PluginRegistryRecord[]> {
return this.context.drizzle.select().from(pluginRegistries);
}
async findById(registryId: string): Promise<PluginRegistryRecord | null> {
const rows = await this.context.drizzle
.select()
.from(pluginRegistries)
.where(eq(pluginRegistries.id, registryId))
.limit(1);
return rows[0] ?? null;
}
async create(
input: PluginRegistryCreateInput,
): Promise<PluginRegistryRecord> {
const [created] = await insertReturning(this.context, pluginRegistries, {
id: input.id,
name: input.name,
url: input.url,
kind: input.kind ?? "community",
enabled: input.enabled ?? true,
signingKey: input.signingKey ?? null,
});
await this.afterWrite();
return created;
}
async update(
registryId: string,
input: PluginRegistryUpdateInput,
): Promise<PluginRegistryRecord | null> {
const existing = await this.findById(registryId);
if (!existing) return null;
const [updated] = await updateReturning(
this.context,
pluginRegistries,
{
name: input.name ?? existing.name,
url: input.url ?? existing.url,
kind: input.kind ?? existing.kind,
enabled: input.enabled ?? existing.enabled,
signingKey:
input.signingKey === undefined
? existing.signingKey
: input.signingKey,
lastCheckedAt:
input.lastCheckedAt === undefined
? existing.lastCheckedAt
: input.lastCheckedAt,
lastIndexHash:
input.lastIndexHash === undefined
? existing.lastIndexHash
: input.lastIndexHash,
},
eq(pluginRegistries.id, registryId),
);
await this.afterWrite();
return updated ?? null;
}
async delete(registryId: string): Promise<boolean> {
const deleted = await deleteReturning(
this.context,
pluginRegistries,
eq(pluginRegistries.id, registryId),
);
if (deleted.length > 0) {
await this.afterWrite();
return true;
}
return false;
}
async deleteAll(): Promise<number> {
const all = await this.listAll();
const result = await this.context.drizzle.delete(pluginRegistries);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return all.length;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -79,7 +79,7 @@ export class UserRepository {
return rows[0] ?? null;
}
async findByOidcIdentifier(
async findByExternalIdentifier(
oidcIdentifier: string,
): Promise<UserRecord | null> {
const rows = await this.context.drizzle
+11 -5
View File
@@ -7,7 +7,7 @@
import type { Request, Response, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
import { ensureCoreLoginProviders } from "../../auth/core-auth.js";
import {
readPendingLogin,
@@ -32,7 +32,7 @@ export interface PublicLoginMethod {
kind: "redirect" | "form";
labelKey: string;
icon?: string;
instances: Array<{ id: string; label: string }>;
instances: Array<{ id: string; label: string; autoStart?: boolean }>;
}
async function enabledInstances(method: LoginMethod) {
@@ -93,12 +93,18 @@ export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
ensureCoreLoginProviders();
const methods: PublicLoginMethod[] = [];
for (const method of listLoginMethods()) {
let instances: Array<{ id: string; label: string }> = [];
let instances: PublicLoginMethod["instances"] = [];
if (method.describe) {
try {
instances = (await method.describe())
.filter((instance) => instance.enabled)
.map((instance) => ({ id: instance.id, label: instance.label }));
.map((instance) => ({
id: instance.id,
label: instance.label,
...(instance.autoStart && method.kind === "redirect"
? { autoStart: true }
: {}),
}));
} catch (error) {
authLogger.warn("Login method could not describe itself", {
operation: "login_method_describe",
@@ -178,7 +184,7 @@ export async function handleRedirectCallback(
res,
identity,
{ methodId: method.id, rememberMe: !!identity.rememberMe },
isOidcTokenCallback,
isExternalTokenCallback,
);
}
-13
View File
@@ -2303,19 +2303,6 @@ registerHostNetworkRoutes(router, {
export default router;
/**
* A config field arriving as a JSON string (an import, or a client that
* stringified it) must still reach storage as an object. Malformed input
* becomes null rather than throwing inside a host save.
*/
function safeParseJson(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
/** Seconds between status checks, or null to follow the global setting. */
function normalizeStatusInterval(value: unknown): number | null {
const seconds = Number(value);
+2 -18
View File
@@ -283,9 +283,8 @@ router.get(
* get:
* summary: Get all active backend sessions for the current user
* description: >
* Returns the live terminal sessions the caller owns. Sessions other
* users shared with the caller come from the session sharing plugin.
* Used by the Active Connections panel and tab restore logic.
* Returns the live terminal sessions the caller owns. Used by the
* Active Connections panel and tab restore logic.
* tags:
* - Open Tabs
* responses:
@@ -310,17 +309,6 @@ router.get(
* type: boolean
* createdAt:
* type: number
* isOwnSession:
* type: boolean
* sharedByUsername:
* type: string
* nullable: true
* permissionLevel:
* type: string
* nullable: true
* shareId:
* type: string
* nullable: true
*/
router.get(
"/active-sessions",
@@ -336,10 +324,6 @@ router.get(
tabInstanceId: s.tabInstanceId,
isConnected: s.isConnected,
createdAt: s.createdAt,
isOwnSession: true,
sharedByUsername: null as string | null,
permissionLevel: null as string | null,
shareId: null as string | null,
}));
return res.json(result);
+14 -2
View File
@@ -25,13 +25,25 @@ export interface SshAuthProviderSummary {
missingPlugin?: { id: string; name: string };
}
/**
* A plugin names its keys relative to its own locales, so they are sent
* qualified with its namespace. Core never has to hold a plugin's strings.
*/
function qualifyKey(
pluginId: string,
key: string | undefined,
): string | undefined {
if (!key || pluginId === "core" || key.includes(":")) return key;
return `${pluginId}:${key}`;
}
function summarizeSshAuthProviders(): SshAuthProviderSummary[] {
ensureCoreSshAuthProviders();
const summaries: SshAuthProviderSummary[] = listSshAuthProviders().map(
(provider) => ({
type: provider.type,
labelKey: provider.labelKey,
descriptionKey: provider.descriptionKey,
labelKey: qualifyKey(provider.pluginId, provider.labelKey) ?? "",
descriptionKey: qualifyKey(provider.pluginId, provider.descriptionKey),
pluginId: provider.pluginId,
fields: provider.fields ?? [],
credentialType: !!provider.credentialType,
@@ -1,3 +1,4 @@
import { isExternalAccount } from "../../auth/external-account.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
@@ -121,6 +122,7 @@ export function registerUserAdminRoutes(
userId: u.id,
username: u.username,
is_admin: u.isAdmin,
is_external: isExternalAccount(u),
is_oidc: u.isOidc,
password_hash: u.passwordHash ? "set" : null,
// Management-only details stay admin-eyes-only; regular users hit
@@ -0,0 +1,285 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentUserRepository,
createCurrentUserAuthRepository,
} from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
import { isExternalAccount } from "../../auth/external-account.js";
type UserExternalAccountRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
/**
* Merging an account that only signs in externally (SSO, LDAP or any login
* plugin) into a password account, and taking the external sign-in away
* again. The identities themselves live in user_external_identities.
*/
export function registerUserExternalAccountRoutes(
router: Router,
{ authenticateJWT, authManager }: UserExternalAccountRoutesDeps,
): void {
/**
* @openapi
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* externalUserId:
* type: string
* targetUsername:
* type: string
* responses:
* 200:
* description: Accounts linked successfully.
* 400:
* description: Invalid request or incompatible accounts.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to link accounts.
*/
const link = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const externalUserId = req.body?.externalUserId ?? req.body?.oidcUserId;
const targetUsername = req.body?.targetUsername;
if (
!isNonEmptyString(externalUserId) ||
!isNonEmptyString(targetUsername)
) {
return res.status(400).json({
error: "External user ID and target username are required",
});
}
try {
const userRepository = createCurrentUserRepository();
const identities = createCurrentUserAuthRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const externalUser = await userRepository.findById(externalUserId);
if (!externalUser) {
return res.status(404).json({ error: "External user not found" });
}
if (!isExternalAccount(externalUser)) {
return res
.status(400)
.json({ error: "Source user does not sign in externally" });
}
const targetUser = await userRepository.findByUsername(targetUsername);
if (!targetUser) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
}
authLogger.info("Linking an external account to a password account", {
operation: "link_external_to_password",
externalUserId,
targetUserId: targetUser.id,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: externalUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
await authManager.revokeAllUserSessions(externalUserId);
authManager.logoutUser(externalUserId);
await deleteUserAndRelatedData(externalUserId);
try {
await DatabaseSaveTrigger.forceSave("link_external_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
saveError,
{
operation: "link_external_save_failed",
externalUserId,
targetUserId: targetUser.id,
},
);
}
authLogger.success(
`External account ${externalUser.username} linked to password account ${targetUser.username}`,
{
operation: "link_external_to_password_success",
externalUserId,
targetUserId: targetUser.id,
adminUserId,
},
);
res.json({
success: true,
message: `${externalUser.username} has been linked to ${targetUser.username}. The password account can now sign in both ways.`,
});
} catch (err) {
authLogger.error("Failed to link an external account", err, {
operation: "link_external_to_password_failed",
externalUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to link accounts",
details: getErrorMessage(err),
});
}
};
/**
* @openapi
* /users/unlink-external-from-password:
* post:
* summary: Remove external sign-in from a password account
* description: Removes every external sign-in identity from an account that also has a password (admin only). The 2.8 path /users/unlink-oidc-from-password is accepted too.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: External sign-in removed.
* 400:
* description: Invalid request, or the user has no external sign-in or no password.
* 403:
* description: Admin privileges required.
* 404:
* description: User not found.
* 500:
* description: Failed to unlink.
*/
const unlink = async (req: Request, res: Response) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { userId } = req.body ?? {};
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
authLogger.warn("Non-admin attempted to remove an external sign-in", {
operation: "unlink_external_unauthorized",
adminUserId,
targetUserId: userId,
});
return res.status(403).json({ error: "Admin privileges required" });
}
const targetUser = await userRepository.findById(userId);
if (!targetUser) {
return res.status(404).json({ error: "User not found" });
}
if (!isExternalAccount(targetUser)) {
return res
.status(400)
.json({ error: "User does not have an external sign-in" });
}
if (!targetUser.passwordHash) {
return res.status(400).json({
error:
"Cannot remove the external sign-in from a user without a password. This would leave the user unable to sign in.",
});
}
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
try {
await DatabaseSaveTrigger.forceSave("unlink_external_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to save database after removing an external sign-in",
saveError,
{
operation: "unlink_external_save_failed",
targetUserId: targetUser.id,
},
);
}
authLogger.success("External sign-in removed from password account", {
operation: "unlink_external_from_password_success",
targetUserId: targetUser.id,
adminUserId,
});
res.json({
success: true,
message: `External sign-in has been removed from ${targetUser.username}. The user can now only sign in with a password.`,
});
} catch (err) {
authLogger.error("Failed to remove an external sign-in", err, {
operation: "unlink_external_from_password_failed",
targetUserId: userId,
adminUserId,
});
res.status(500).json({
error: "Failed to unlink",
details: getErrorMessage(err),
});
}
};
router.post("/link-external-to-password", authenticateJWT, link);
router.post("/unlink-external-from-password", authenticateJWT, unlink);
// 2.8 paths, kept until 3.0.0.
router.post("/link-oidc-to-password", authenticateJWT, link);
router.post("/unlink-oidc-from-password", authenticateJWT, unlink);
}
@@ -1,318 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { AuthManager } from "../../utils/auth-manager.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { authLogger } from "../../utils/logger.js";
import {
createCurrentUserRepository,
createCurrentUserAuthRepository,
} from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
type UserOidcAccountRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserOidcAccountRoutes(
router: Router,
{ authenticateJWT, authManager }: UserOidcAccountRoutesDeps,
): void {
/**
* @openapi
* /users/link-oidc-to-password:
* post:
* summary: Link OIDC user to password account
* description: Merges an OIDC-only account into a password-based account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* oidcUserId:
* type: string
* targetUsername:
* type: string
* responses:
* 200:
* description: Accounts linked successfully.
* 400:
* description: Invalid request or incompatible accounts.
* 403:
* description: Admin access required.
* 404:
* description: User not found.
* 500:
* description: Failed to link accounts.
*/
router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { oidcUserId, targetUsername } = req.body;
if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) {
return res.status(400).json({
error: "OIDC user ID and target username are required",
});
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
return res.status(403).json({ error: "Admin access required" });
}
const oidcUser = await userRepository.findById(oidcUserId);
if (!oidcUser) {
return res.status(404).json({ error: "OIDC user not found" });
}
if (!oidcUser.isOidc) {
return res.status(400).json({
error: "Source user is not an OIDC user",
});
}
const targetUser = await userRepository.findByUsername(targetUsername);
if (!targetUser) {
return res
.status(404)
.json({ error: "Target password user not found" });
}
if (targetUser.isOidc || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if (targetUser.clientId && targetUser.oidcIdentifier) {
return res.status(400).json({
error: "Target user already has OIDC authentication configured",
});
}
authLogger.info("Linking OIDC user to password account", {
operation: "link_oidc_to_password",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: oidcUser.oidcIdentifier,
clientId: oidcUser.clientId,
clientSecret: oidcUser.clientSecret,
issuerUrl: oidcUser.issuerUrl,
authorizationUrl: oidcUser.authorizationUrl,
tokenUrl: oidcUser.tokenUrl,
identifierPath: oidcUser.identifierPath,
namePath: oidcUser.namePath,
scopes: oidcUser.scopes || "openid email profile",
});
// Identities hang off the user row and would go with it.
await createCurrentUserAuthRepository().moveIdentities(
oidcUserId,
targetUser.id,
);
await authManager.revokeAllUserSessions(oidcUserId);
authManager.logoutUser(oidcUserId);
await deleteUserAndRelatedData(oidcUserId);
try {
await DatabaseSaveTrigger.forceSave("link_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to persist account linking to disk",
saveError,
{
operation: "link_oidc_save_failed",
oidcUserId,
targetUserId: targetUser.id,
},
);
}
authLogger.success(
`OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`,
{
operation: "link_oidc_to_password_success",
oidcUserId,
oidcUsername: oidcUser.username,
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
},
);
res.json({
success: true,
message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`,
});
} catch (err) {
authLogger.error("Failed to link OIDC user to password account", err, {
operation: "link_oidc_to_password_failed",
oidcUserId,
targetUsername,
adminUserId,
});
res.status(500).json({
error: "Failed to link accounts",
details: getErrorMessage(err),
});
}
});
/**
* @openapi
* /users/unlink-oidc-from-password:
* post:
* summary: Unlink OIDC from password account
* description: Removes OIDC authentication from a dual-auth account (admin only).
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* responses:
* 200:
* description: OIDC unlinked successfully.
* 400:
* description: Invalid request or user doesn't have OIDC.
* 403:
* description: Admin privileges required.
* 404:
* description: User not found.
* 500:
* description: Failed to unlink OIDC.
*/
router.post(
"/unlink-oidc-from-password",
authenticateJWT,
async (req, res) => {
const adminUserId = (req as AuthenticatedRequest).userId;
const { userId } = req.body;
if (!userId) {
return res.status(400).json({
error: "User ID is required",
});
}
try {
const userRepository = createCurrentUserRepository();
const adminUser = await userRepository.findById(adminUserId);
if (!adminUser?.isAdmin) {
authLogger.warn("Non-admin attempted to unlink OIDC from password", {
operation: "unlink_oidc_unauthorized",
adminUserId,
targetUserId: userId,
});
return res.status(403).json({
error: "Admin privileges required",
});
}
const targetUser = await userRepository.findById(userId);
if (!targetUser) {
return res.status(404).json({
error: "User not found",
});
}
if (!targetUser.isOidc) {
return res.status(400).json({
error: "User does not have OIDC authentication enabled",
});
}
if (!targetUser.passwordHash || targetUser.passwordHash === "") {
return res.status(400).json({
error:
"Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.",
});
}
authLogger.info("Unlinking OIDC from password account", {
operation: "unlink_oidc_from_password_start",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
await userRepository.update(targetUser.id, {
isOidc: false,
oidcIdentifier: null,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
});
await createCurrentUserAuthRepository().unlinkIdentitiesForUser(
targetUser.id,
);
try {
await DatabaseSaveTrigger.forceSave("unlink_oidc_explicit_save");
} catch (saveError) {
authLogger.error(
"Failed to save database after unlinking OIDC",
saveError,
{
operation: "unlink_oidc_save_failed",
targetUserId: targetUser.id,
},
);
}
authLogger.success("OIDC unlinked from password account successfully", {
operation: "unlink_oidc_from_password_success",
targetUserId: targetUser.id,
targetUsername: targetUser.username,
adminUserId,
});
res.json({
success: true,
message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`,
});
} catch (err) {
authLogger.error("Failed to unlink OIDC from password account", err, {
operation: "unlink_oidc_from_password_failed",
targetUserId: userId,
adminUserId,
});
res.status(500).json({
error: "Failed to unlink OIDC",
details: getErrorMessage(err),
});
}
},
);
}
@@ -20,7 +20,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
accentColor: row?.accentColor ?? null,
language: row?.language ?? null,
storageMode: row?.storageMode ?? "cloud",
commandAutocomplete: row?.commandAutocomplete ?? null,
commandPaletteEnabled: row?.commandPaletteEnabled ?? null,
showHostTags: row?.showHostTags ?? null,
hostTrayOnClick: row?.hostTrayOnClick ?? null,
@@ -32,23 +31,9 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
hiddenRailTabs: row?.hiddenRailTabs ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customThemes: row?.customThemes ?? null,
customKeybindings: row?.customKeybindings ?? null,
terminalDefaults: row?.terminalDefaults ?? null,
});
const connectionDefaultFields = ["terminalDefaults"] as const;
export function validateDefaultsJson(value: string): boolean {
if (value.length > 32_768) return false;
try {
const parsed = JSON.parse(value);
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/**
* @openapi
* /user-preferences:
@@ -82,9 +67,6 @@ export function validateDefaultsJson(value: string): boolean {
* storageMode:
* type: string
* nullable: true
* commandAutocomplete:
* type: boolean
* nullable: true
* commandPaletteEnabled:
* type: boolean
* nullable: true
@@ -118,10 +100,6 @@ export function validateDefaultsJson(value: string): boolean {
* statusColorScheme:
* type: string
* nullable: true
* customThemes:
* type: string
* nullable: true
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* nullable: true
@@ -170,8 +148,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: string
* storageMode:
* type: string
* commandAutocomplete:
* type: boolean
* commandPaletteEnabled:
* type: boolean
* pinAppRail:
@@ -186,9 +162,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* hiddenRailTabs:
* type: string
* customThemes:
* type: string
* description: JSON-encoded array of the user's saved global custom terminal themes.
* customKeybindings:
* type: string
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
@@ -205,7 +178,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
accentColor,
language,
storageMode,
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
@@ -213,9 +185,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -223,7 +193,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
accentColor?: string | null;
language?: string | null;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
@@ -231,9 +200,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
};
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
// longer writable here -- they moved to /host-sidebar/preferences as of the
@@ -262,55 +229,13 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
language,
storageMode,
hiddenRailTabs,
customThemes,
customKeybindings,
terminalDefaults,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
const connectionDefaults = {
terminalDefaults,
};
for (const key of connectionDefaultFields) {
const value = connectionDefaults[key];
if (value !== undefined && value !== null && !validateDefaultsJson(value)) {
return res.status(400).json({
error: `${key} must be a JSON-encoded object of at most 32 KiB`,
});
}
}
if (customThemes !== undefined && customThemes !== null) {
let parsedThemes: unknown;
try {
parsedThemes = JSON.parse(customThemes);
} catch {
return res
.status(400)
.json({ error: "customThemes must be a JSON-encoded array" });
}
if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
return res.status(400).json({
error: "customThemes must be a JSON array of at most 100 themes",
});
}
const isValidTheme = (entry: unknown): boolean =>
!!entry &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string" &&
!!(entry as { colors?: unknown }).colors &&
typeof (entry as { colors?: unknown }).colors === "object";
if (!parsedThemes.every(isValidTheme)) {
return res.status(400).json({
error: "Each custom theme must have an id, name, and colors object",
});
}
}
if (customKeybindings !== undefined && customKeybindings !== null) {
let parsedKeybindings: unknown;
try {
@@ -334,7 +259,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
const boolFields: Record<string, boolean | null | undefined> = {
commandAutocomplete,
commandPaletteEnabled,
pinAppRail,
expandAppRailOnHover,
@@ -354,8 +278,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (language !== undefined) updates.language = language;
if (storageMode !== undefined) updates.storageMode = storageMode;
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
if (commandAutocomplete !== undefined)
updates.commandAutocomplete = commandAutocomplete;
if (commandPaletteEnabled !== undefined)
updates.commandPaletteEnabled = commandPaletteEnabled;
if (pinAppRail !== undefined) updates.pinAppRail = pinAppRail;
@@ -366,11 +288,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (disableUpdateCheck !== undefined)
updates.disableUpdateCheck = disableUpdateCheck;
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
if (customThemes !== undefined) updates.customThemes = customThemes;
if (customKeybindings !== undefined)
updates.customKeybindings = customKeybindings;
if (terminalDefaults !== undefined)
updates.terminalDefaults = terminalDefaults;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
@@ -341,25 +341,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/session-sharing-enabled:
* get:
* summary: Get session sharing globally enabled setting
* description: Returns whether live session sharing (terminal and remote desktop share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
* tags:
* - Users
* responses:
* 200:
* description: Session sharing enabled status.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
*/
/**
* @openapi
* /users/audit-forwarding:
+39 -125
View File
@@ -1,5 +1,10 @@
import { isExternalAccount } from "../../auth/external-account.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import express, { type Request, type Response } from "express";
import express, {
type Request,
type RequestHandler,
type Response,
} from "express";
import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { authLogger } from "../../utils/logger.js";
@@ -21,7 +26,7 @@ import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
@@ -90,12 +95,6 @@ function isPasswordResetAllowed(): boolean {
}
}
function getOidcSilentLoginDefaultFromEnv(): boolean | undefined {
const envVal = process.env.OIDC_SILENT_LOGIN_DEFAULT;
if (envVal === undefined) return undefined;
return envVal.trim().toLowerCase() === "true";
}
async function findCurrentUser(userId: string): Promise<UserRecord | null> {
return createCurrentUserRepository().findById(userId);
}
@@ -601,8 +600,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
}
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const hasOidc = user.isOidc && user.oidcIdentifier;
const isDualAuth = hasPassword && hasOidc;
const isDualAuth =
hasPassword && isExternalAccount(user) && !!user.oidcIdentifier;
const showDonationModal = shouldShowDonationModal(
user.registeredAt,
@@ -613,6 +612,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
userId: user.id,
username: user.username,
is_admin: !!user.isAdmin,
is_external: isExternalAccount(user),
// 2.8 name, kept until 3.0.0.
is_oidc: !!user.isOidc,
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
@@ -940,17 +941,17 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
/**
* @openapi
* /users/oidc-auto-provision:
* /users/external-auto-provision:
* get:
* summary: Get OIDC auto-provision status
* description: Whether a new user account is created automatically on first SSO sign-in.
* summary: Get the external account auto-create setting
* description: Whether a new account is created the first time someone signs in through an external login (SSO, LDAP or any login plugin). The 2.8 path /users/oidc-auto-provision is accepted too.
* tags:
* - Users
* responses:
* 200:
* description: OIDC auto-provision status.
* description: Auto-create setting.
*/
router.get("/oidc-auto-provision", async (_req, res) => {
const getExternalAutoProvision: RequestHandler = async (_req, res) => {
try {
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
@@ -959,19 +960,19 @@ router.get("/oidc-auto-provision", async (_req, res) => {
),
});
} catch (err) {
authLogger.error("Failed to get OIDC auto-provision setting", err);
authLogger.error("Failed to get external auto-provision setting", err);
res
.status(500)
.json({ error: "Failed to get OIDC auto-provision setting" });
.json({ error: "Failed to get external auto-provision setting" });
}
});
};
/**
* @openapi
* /users/oidc-auto-provision:
* /users/external-auto-provision:
* patch:
* summary: Set OIDC auto-provision status
* description: Enables or disables automatic account creation on first SSO sign-in.
* summary: Set the external account auto-create setting
* description: Enables or disables creating an account on first external sign-in. The 2.8 path /users/oidc-auto-provision is accepted too.
* tags:
* - Users
* requestBody:
@@ -985,15 +986,15 @@ router.get("/oidc-auto-provision", async (_req, res) => {
* type: boolean
* responses:
* 200:
* description: OIDC auto-provision status updated.
* description: Setting updated.
* 400:
* description: Invalid value for enabled.
* 403:
* description: Not authorized.
* 500:
* description: Failed to set OIDC auto-provision setting.
* description: Failed to save the setting.
*/
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
const setExternalAutoProvision: RequestHandler = async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await requireCurrentAdmin(userId);
@@ -1010,10 +1011,20 @@ router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
);
res.json({ enabled });
} catch (err) {
authLogger.error("Failed to set OIDC auto-provision", err);
res.status(500).json({ error: "Failed to set OIDC auto-provision" });
authLogger.error("Failed to set external auto-provision", err);
res.status(500).json({ error: "Failed to set external auto-provision" });
}
});
};
router.get("/external-auto-provision", getExternalAutoProvision);
router.patch(
"/external-auto-provision",
authenticateJWT,
setExternalAutoProvision,
);
// 2.8 paths, kept until 3.0.0.
router.get("/oidc-auto-provision", getExternalAutoProvision);
router.patch("/oidc-auto-provision", authenticateJWT, setExternalAutoProvision);
/**
* @openapi
@@ -1104,103 +1115,6 @@ router.patch(
},
);
/**
* @openapi
* /users/oidc-silent-login-default:
* get:
* summary: Get OIDC silent login default setting
* description: Returns whether silent OIDC login is enabled as the default behavior. Can be pinned via the OIDC_SILENT_LOGIN_DEFAULT env var.
* tags:
* - Users
* responses:
* 200:
* description: Silent login default setting.
* 500:
* description: Failed to get setting.
*/
router.get("/oidc-silent-login-default", async (_req, res) => {
try {
const envVal = getOidcSilentLoginDefaultFromEnv();
if (envVal !== undefined) {
res.json({ enabled: envVal, locked: true });
return;
}
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"oidc_silent_login_default",
false,
),
locked: false,
});
} catch (err) {
authLogger.error("Failed to get OIDC silent login default", err);
res.status(500).json({ error: "Failed to get OIDC silent login default" });
}
});
/**
* @openapi
* /users/oidc-silent-login-default:
* patch:
* summary: Set OIDC silent login default setting
* description: Enables or disables silent OIDC login as the default behavior on the login page.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
* 409:
* description: Setting is pinned by the OIDC_SILENT_LOGIN_DEFAULT env var.
* 500:
* description: Failed to update setting.
*/
router.patch(
"/oidc-silent-login-default",
authenticateJWT,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const user = await requireCurrentAdmin(userId);
if (!user) {
return res.status(403).json({ error: "Not authorized" });
}
if (getOidcSilentLoginDefaultFromEnv() !== undefined) {
return res.status(409).json({
error:
"OIDC silent login default is set via the OIDC_SILENT_LOGIN_DEFAULT env var and cannot be changed here",
});
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "Invalid value for enabled" });
}
await createCurrentSettingsRepository().set(
"oidc_silent_login_default",
enabled ? "true" : "false",
);
res.json({ enabled });
} catch (err) {
authLogger.error("Failed to set OIDC silent login default", err);
res
.status(500)
.json({ error: "Failed to set OIDC silent login default" });
}
},
);
/**
* @openapi
* /users/password-login-allowed:
@@ -1668,7 +1582,7 @@ registerUserSessionRoutes(router, {
authManager,
});
registerUserOidcAccountRoutes(router, {
registerUserExternalAccountRoutes(router, {
authenticateJWT,
authManager,
});
+14 -10
View File
@@ -34,16 +34,20 @@ export function pickResolvedPassword(
return undefined;
}
const USERNAME_PLACEHOLDER =
/\$(?:external\.username|oidc\.preferred_username)/g;
/**
* Expands the `$oidc.preferred_username` placeholder in an SSH username to the
* connecting user's OIDC identifier. Returns the username unchanged if it does
* not contain the placeholder or the user has no OIDC identifier.
* Expands `$external.username` (or its 2.8 spelling `$oidc.preferred_username`)
* in an SSH username to the name the connecting user signed in with through
* SSO or LDAP. Returns the username unchanged if it has no placeholder or the
* user has no external sign-in.
*/
export async function expandOidcUsername(
export async function expandExternalUsername(
username: string | undefined,
userId: string,
): Promise<string | undefined> {
if (!username || !username.includes("$oidc.preferred_username")) {
if (!username || !new RegExp(USERNAME_PLACEHOLDER.source).test(username)) {
return username;
}
@@ -51,10 +55,10 @@ export async function expandOidcUsername(
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const user = await createCurrentUserRepository().findById(userId);
let oidcIdentifier = user?.oidcIdentifier;
if (!oidcIdentifier) return username;
let externalName = user?.oidcIdentifier;
if (!externalName) return username;
const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier);
const match = /^ldap:(\d+):(.+)$/.exec(externalName);
if (match) {
// Only strip the prefix for a real LDAP identity, to prevent spoofing
// through an SSO subject that happens to look like one.
@@ -69,11 +73,11 @@ export async function expandOidcUsername(
identity.subject === match[2],
)
) {
oidcIdentifier = match[2];
externalName = match[2];
}
}
return username.replace(/\$oidc\.preferred_username/g, oidcIdentifier);
return username.replace(USERNAME_PLACEHOLDER, () => externalName);
} catch {
return username;
}
+2 -2
View File
@@ -13,7 +13,7 @@ import { resolveRecipientSharedHostAuthentication } from "../utils/shared-host-a
import {
pickResolvedPassword,
pickResolvedUsername,
expandOidcUsername,
expandExternalUsername,
} from "./credential-username.js";
import type { SSHHost } from "../../types/index.js";
@@ -210,7 +210,7 @@ export async function resolveHostById(
}
}
host.username = await expandOidcUsername(
host.username = await expandExternalUsername(
host.username as string | undefined,
ownerEquivalent ? ownerId : userId,
);
@@ -22,7 +22,6 @@ export interface LiveSessionInfo {
createdAt: number;
lastDetachedAt: number | null;
tabInstanceId: string | null;
tmuxSessionName: string | null;
cols: number;
rows: number;
}
-12
View File
@@ -46,15 +46,3 @@ export function getActorSessionId(): string | undefined {
export function getActor(): string | undefined {
return storage.getStore()?.userId;
}
class PluginActorError extends Error {
readonly code = "EPLUGINACTOR";
constructor(pluginId: string, operation: string) {
super(
`Plugin "${pluginId}" called ${operation} with no acting user. ` +
`Wrap background work in ctx.asUser(userId, fn).`,
);
this.name = "PluginActorError";
}
}
+24 -6
View File
@@ -93,12 +93,7 @@ export function describePluginFrontend(
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
const stat = fs.statSync(entry.file);
const assetVersion = crypto
.createHash("sha256")
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
.digest("hex")
.slice(0, 12);
const assetVersion = contentVersion(entry.file, plugin.manifest.version);
return {
frontend: true,
@@ -108,6 +103,29 @@ export function describePluginFrontend(
};
}
const versionCache = new Map<string, { stamp: string; version: string }>();
/**
* A hash of the bundle's bytes, not its mtime: assets are cached as immutable
* for a year, and a rebuilt .tmxplug has a fixed mtime, so a same-size rebuild
* would otherwise keep serving the stale copy.
*/
function contentVersion(file: string, version: string): string {
const stat = fs.statSync(file);
// ctime, unlike mtime, cannot be set by tar, so an unpack always changes it.
const stamp = `${version}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
const cached = versionCache.get(file);
if (cached?.stamp === stamp) return cached.version;
const hashed = crypto
.createHash("sha256")
.update(version)
.update(fs.readFileSync(file))
.digest("hex")
.slice(0, 12);
versionCache.set(file, { stamp, version: hashed });
return hashed;
}
/**
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
*
-1
View File
@@ -81,7 +81,6 @@ export const HOST_SECRET_FIELDS = [
"passphrase",
"sudoPassword",
"socks5Password",
"vaultToken",
] as const;
/** A copy of a host with every secret field removed, nested ones included. */
+29 -1
View File
@@ -51,6 +51,34 @@ async function sha256Of(file: string): Promise<string | null> {
}
}
/**
* Downloads through the SSRF guard, following https redirects by hand
* because the guard refuses automatic ones (release assets redirect to a
* CDN).
*/
async function guardedDownload(
url: URL | string,
init: RequestInit,
): Promise<Response> {
const { safeOutboundFetch } = await import("../utils/safe-outbound-fetch.js");
let current = new URL(url.toString());
for (let hop = 0; hop < 5; hop++) {
const response = await safeOutboundFetch(current.toString(), {
...init,
redirect: "manual",
});
const location = response.headers.get("location");
if (response.status < 300 || response.status >= 400 || !location) {
return response;
}
current = new URL(location, current);
if (current.protocol !== "https:") {
throw new Error("Binary downloads must stay on https");
}
}
throw new Error("Too many redirects");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -60,7 +88,7 @@ export function createPluginProcess(deps: Deps): PluginProcess {
const declared = deps.manifest.capabilities;
const binDir =
deps.binDir ?? (() => path.join(getPluginDataDir(pluginId), "bin"));
const doFetch = deps.fetch ?? fetch;
const doFetch = deps.fetch ?? (guardedDownload as typeof fetch);
const audited = async <T>(
action: string,
+6 -5
View File
@@ -791,11 +791,12 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await granted();
if (!match.sub && !match.sid) return 0;
const { AuthManager } = await import("../utils/auth-manager.js");
const revoked = await AuthManager.getInstance().revokeSessionsByOidc({
ssoProviderId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
const revoked =
await AuthManager.getInstance().revokeSessionsByExternalSession({
ssoProviderId: match.providerId ?? null,
sub: match.sub ?? null,
sid: match.sid ?? null,
});
await audit(
"auth_sessions_revoked",
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
+83 -22
View File
@@ -47,7 +47,7 @@ import {
type PluginContext,
type PluginModule,
type PluginOpenIsolatedWindowRequest,
type PluginNativeRdpRequest,
type PluginExternalClientRequest,
} from "@termix/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
@@ -191,14 +191,17 @@ export function createPluginContext(
await import("../database/repositories/factory.js");
return createCurrentPluginSettingsRepository();
};
// A user named by the plugin instead of the ambient actor. Honoured, like
// ctx.asUser, and audited the same way.
// A user named by the plugin instead of the ambient actor. The same thing
// as ctx.asUser, so it needs the same capability and gets the same audit.
const namedUser = (
userId: string | undefined,
via: string,
): string | undefined => {
if (!userId) return getActor();
if (userId !== getActor()) {
if (!declared.includes("users:impersonate")) {
throw capabilityRefused(pluginId, "users:impersonate");
}
void writeAudit(
manifest,
{
@@ -342,11 +345,11 @@ export function createPluginContext(
const dbRefs = guarded(
manifest,
"db:own",
"db:core-refs",
async () => {
// Read-only by convention, not by engine: a plugin holding these can
// write through them. The capability and the audit line are the record
// that it did. See the header of this file.
// write through them, which is why the capability is rated high. The
// audit line is the record that it asked.
const schema = await import("../database/db/schema.js");
return {
users: schema.users,
@@ -380,25 +383,25 @@ export function createPluginContext(
},
);
const desktopLaunchNativeRdp = guarded(
const desktopLaunchExternalClient = guarded(
manifest,
"desktop:window",
async (request: PluginNativeRdpRequest) => {
async (request: PluginExternalClientRequest) => {
const { isElectronIpcAvailable, requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
if (!isElectronIpcAvailable()) {
throw new Error(
`Plugin ${pluginId} tried to open the native RDP client outside the desktop app`,
`Plugin ${pluginId} tried to open an external client outside the desktop app`,
);
}
return requestFromElectronMain<{ success: boolean; error?: string }>(
"launch-native-rdp",
"launch-external-client",
request,
);
},
{
action: "desktop_launch_native_rdp",
details: () => "opened the native RDP client",
action: "desktop_launch_external_client",
details: () => "opened an external client",
},
);
@@ -455,7 +458,26 @@ export function createPluginContext(
`Declare the events:core capability to listen to core topics.`,
);
}
const unsubscribe = pluginEvents.on(topic, listener);
// A throwing listener counts against the plugin's error budget.
const counted: typeof listener = (payload) => {
try {
const result = listener(payload) as unknown;
if (
result &&
typeof (result as Promise<void>).then === "function"
) {
return (result as Promise<void>).catch((error) => {
void reportRuntimeError(pluginId, error);
throw error;
});
}
return result as never;
} catch (error) {
void reportRuntimeError(pluginId, error);
throw error;
}
};
const unsubscribe = pluginEvents.on(topic, counted);
handle.bag.add(unsubscribe, `event listener for "${topic}"`);
return unsubscribe;
},
@@ -502,7 +524,10 @@ export function createPluginContext(
},
registry: {
// Keys are namespaced by plugin id, so one plugin cannot replace or
// remove what another provides. Anyone may consume.
provide: (key, value) => {
assertOwnRegistryKey(pluginId, key);
registry.provide(key, value);
handle.bag.add(
() => void registry.revoke(key, value),
@@ -510,7 +535,10 @@ export function createPluginContext(
);
},
consume: (key) => registry.consume(key),
revoke: (key, value) => registry.revoke(key, value),
revoke: (key, value) => {
assertOwnRegistryKey(pluginId, key);
return registry.revoke(key, value);
},
},
services: {
@@ -548,8 +576,18 @@ export function createPluginContext(
);
},
get: (service, options) =>
serviceRegistry.createServiceHandle(
get: (service, options) => {
// Declared, like getShared: a plugin reaches only the services its
// manifest names in requires, or its own.
const known =
manifest.requires?.some((entry) => entry.service === service) ||
manifest.provides?.some((entry) => entry.service === service);
if (!known) {
throw new Error(
`Plugin ${pluginId} cannot use service "${service}": it is not declared in the manifest's requires array`,
);
}
return serviceRegistry.createServiceHandle(
service,
pluginId,
{
@@ -562,7 +600,8 @@ export function createPluginContext(
audit: (entry) => writeServiceAudit(entry),
},
options?.provider ?? "",
),
);
},
providers: (service) => serviceRegistry.listProviderNames(service),
},
@@ -709,10 +748,20 @@ export function createPluginContext(
if (!declared.includes("network:serve")) {
throw capabilityRefused(pluginId, "network:serve");
}
// Only a signed-in caller's socket counts against the error budget,
// so an anonymous peer cannot switch the plugin off.
const counted: typeof wsHandler = async (connection) => {
try {
return await wsHandler(connection);
} catch (error) {
if (connection.userId) void reportRuntimeError(pluginId, error);
throw error;
}
};
const dispose = registerPluginWsRoute(
pluginId,
path,
wsHandler,
counted,
declared,
options,
);
@@ -840,13 +889,14 @@ export function createPluginContext(
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
schedule: createPluginSchedule(handle.bag, (message, error) =>
schedule: createPluginSchedule(handle.bag, (message, error) => {
pluginLogger.error(
message,
error instanceof Error ? error : new Error(String(error)),
logContext,
),
),
);
void reportRuntimeError(pluginId, error);
}),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
notify: createPluginNotify({
manifest,
@@ -864,7 +914,7 @@ export function createPluginContext(
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
launchNativeRdp: (request) => desktopLaunchNativeRdp(request),
launchExternalClient: (request) => desktopLaunchExternalClient(request),
available: () => isElectronIpcAvailable(),
},
@@ -909,6 +959,9 @@ export function createPluginContext(
`Plugin ${pluginId} called ctx.asUser without a user id`,
);
}
if (!declared.includes("users:impersonate")) {
throw capabilityRefused(pluginId, "users:impersonate");
}
await writeAudit(
manifest,
{
@@ -942,6 +995,14 @@ async function reportRuntimeError(
}
}
function assertOwnRegistryKey(pluginId: string, key: string): void {
if (typeof key !== "string" || !key.startsWith(`${pluginId}.`)) {
throw new Error(
`Plugin ${pluginId} may only use registry keys under "${pluginId}.", not "${key}"`,
);
}
}
function assertKvKey(key: string): void {
if (typeof key !== "string" || key.length === 0) {
throw new Error("ctx.kv keys must be non-empty strings");
+75 -11
View File
@@ -30,14 +30,19 @@ export function registerTable(
definition: PluginTableDefinition,
): unknown {
const definitions = registered.get(pluginId) ?? [];
const existing = definitions.find((entry) => entry.name === definition.name);
if (existing) {
// Re-registering on re-activation is normal: the module is cached, so
// activate runs again against the same definitions.
return getTable(pluginId, definition.name);
const index = definitions.findIndex(
(entry) => entry.name === definition.name,
);
if (index >= 0) {
// Re-registering on re-activation is normal. The same definition keeps
// its table object; a changed one (an updated plugin) is rebuilt.
if (JSON.stringify(definitions[index]) === JSON.stringify(definition)) {
return getTable(pluginId, definition.name);
}
definitions[index] = definition;
} else {
definitions.push(definition);
}
definitions.push(definition);
registered.set(pluginId, definitions);
const table = buildTable(pluginId, definition);
@@ -86,12 +91,65 @@ async function execute(statements: string[]): Promise<void> {
}
}
/**
* Postgres runs DDL inside a transaction, so one migration file applies
* whole or not at all. Through drizzle's transaction because a pool would
* spread a raw BEGIN and the statements over different connections.
*/
async function executeInTransaction(statements: string[]): Promise<void> {
const { getDb } = await import("../database/db/index.js");
const db = getDb() as unknown as {
transaction: (
fn: (tx: {
execute: (query: unknown) => Promise<unknown>;
}) => Promise<void>,
) => Promise<void>;
};
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.execute(sql.raw(statement));
}
});
}
/**
* SQLite has one connection, so a raw BEGIN covers the statements and the
* migration record together.
*/
async function sqliteTransaction(fn: () => Promise<void>): Promise<void> {
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
await runStatement(sql.raw("BEGIN"));
try {
await fn();
await runStatement(sql.raw("COMMIT"));
} catch (error) {
await runStatement(sql.raw("ROLLBACK")).catch(() => {});
throw error;
}
}
async function executeNamingFailure(statements: string[]): Promise<void> {
const { runStatement } =
await import("../utils/crypto-migration/raw-rows.js");
for (const [index, statement] of statements.entries()) {
try {
await runStatement(sql.raw(statement));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`statement ${index + 1} of ${statements.length} failed (earlier ones are already applied on MySQL): ${message}`,
);
}
}
}
/**
* Builds the runner that applies migrations against the live database.
*
* No transaction on any engine: MySQL commits implicitly on DDL, and each
* migration is recorded once it has run, so a retry after a failure starts
* at the migration that failed.
* SQLite and Postgres apply each migration file atomically. MySQL commits
* implicitly on DDL, so there a failed file can leave earlier statements
* applied; the error names the statement so it can be fixed by hand.
*/
async function createMigrationRunner(
dialect: DatabaseDialect = resolveDatabaseDialect(),
@@ -109,7 +167,13 @@ async function createMigrationRunner(
checksum: row.checksum,
}));
},
execute,
execute:
dialect === "postgres"
? executeInTransaction
: dialect === "mysql"
? executeNamingFailure
: execute,
transaction: dialect === "sqlite" ? sqliteTransaction : undefined,
record: async (pluginId, migration) => {
await repository.record(pluginId, migration.id, migration.checksum);
},
+47
View File
@@ -38,6 +38,43 @@ const DEFAULT_BODY_LIMIT = "2mb";
/** Routers by plugin id, read by the dispatcher in plugin-api-routes.ts. */
const routers = new Map<string, Router>();
/**
* Requests per minute one address may make to one plugin's public routes.
* They run without a login, so they are the cheap way to hammer a plugin.
*/
function publicRateLimit(): number {
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
}
const PUBLIC_RATE_WINDOW_MS = 60_000;
const publicHits = new Map<string, { count: number; resetAt: number }>();
/** True when this request is over the public route limit. */
export function overPublicRateLimit(
pluginId: string,
ip: string,
now = Date.now(),
): boolean {
if (publicHits.size > 10_000) {
for (const [hitKey, entry] of publicHits) {
if (entry.resetAt <= now) publicHits.delete(hitKey);
}
}
const hitKey = `${pluginId}|${ip}`;
const entry = publicHits.get(hitKey);
if (!entry || entry.resetAt <= now) {
publicHits.set(hitKey, { count: 1, resetAt: now + PUBLIC_RATE_WINDOW_MS });
return false;
}
entry.count++;
return entry.count > publicRateLimit();
}
/** Test seam. */
export function resetPublicRateLimits(): void {
publicHits.clear();
}
/** Declared public paths by plugin id, for the admin plugin details. */
const publicRoutes = new Map<string, string[]>();
@@ -151,6 +188,11 @@ export function createPluginRouter({
reportError,
}: CreatePluginRouterArgs): Router {
const pluginId = manifest.id;
if (routers.has(pluginId)) {
throw new Error(
`Plugin ${pluginId} already has a router; call ctx.http.router() once`,
);
}
const declaredPublic = (options?.public ?? []).map((path) =>
normalizePublicPath(path),
);
@@ -209,6 +251,11 @@ export function createPluginRouter({
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths)) {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
if (overPublicRateLimit(pluginId, ip)) {
res.status(429).json({ error: "Too many requests" });
return;
}
pluginLogger.info(`Unauthenticated request to ${pluginId}${req.path}`, {
operation: "plugin_http_public_request",
});
+29 -7
View File
@@ -29,7 +29,9 @@ let loader: PluginLoader | null = null;
export function getPluginRuntime(): { loader: PluginLoader } {
if (!loader) {
loader = new PluginLoader();
loader = new PluginLoader({
onFailed: (plugin) => void handlePluginFailed(plugin),
});
// The 503-while-disabled answer comes from here rather than from the
// router being torn down, so a request that arrives mid-disable gets a
// truthful status instead of a 404.
@@ -91,6 +93,29 @@ export function getPluginRuntime(): { loader: PluginLoader } {
return { loader };
}
/**
* A plugin that tripped the error budget is off until an admin retries it, so
* it has to look off everywhere: no routes, greyed permissions, and a failed
* row that survives a restart.
*/
async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
unregisterPluginHttp(plugin.id);
try {
if (plugin.manifest.contributes?.permissions?.length) {
const { markPluginPermissionsDisabled } =
await import("../utils/permission-catalog.js");
markPluginPermissionsDisabled(plugin.id);
}
await persistRuntimeState([plugin]);
} catch (error) {
pluginLogger.error(
`Could not record that plugin ${plugin.id} failed`,
error instanceof Error ? error : new Error(String(error)),
{ operation: "plugin_runtime" },
);
}
}
/**
* Gives every discovered plugin a row, and refreshes the stored manifest.
*
@@ -533,13 +558,10 @@ export async function installPluginArtifact(
file: string,
): Promise<LoadedPlugin> {
const { loader: pluginLoader } = getPluginRuntime();
const bundledIds = new Set(
pluginLoader
.list()
.filter((plugin) => plugin.source === "bundled")
.map((plugin) => plugin.id),
const plugin = await pluginLoader.loadArtifact(
file,
pluginLoader.bundledIds(),
);
const plugin = await pluginLoader.loadArtifact(file, bundledIds);
await seedPlugins([plugin]);
await syncCapabilityGrants([plugin]);
return plugin;
+129 -37
View File
@@ -18,7 +18,8 @@ import path from "node:path";
import semver from "semver";
import * as tar from "tar";
import { pluginLogger } from "../utils/logger.js";
import { parseManifest } from "./manifest.js";
import { isTermixCompatible, parseManifest } from "./manifest.js";
import { getLocalVersion } from "../utils/app-version.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
getBundledPluginsDir,
@@ -67,6 +68,13 @@ export interface LoadedPlugin {
lastError: string | null;
/** Timestamps of recent runtime errors, trimmed to the window. */
errorTimestamps: number[];
/**
* The .tmxplug this copy was unpacked from. Set for user plugins and for a
* signed update that replaced a bundled plugin.
*/
artifact?: string;
/** Key id that signed the artifact, when a trusted key signed it. */
signedBy?: string;
}
export interface PluginLoaderOptions {
@@ -78,6 +86,8 @@ export interface PluginLoaderOptions {
export class PluginLoader {
private readonly plugins = new Map<string, LoadedPlugin>();
/** Version of each bundled plugin as shipped, the floor for any update. */
private readonly bundledVersions = new Map<string, string>();
/** Activation order, so shutdown can run it backwards. */
private activationOrder: string[] = [];
@@ -91,6 +101,16 @@ export class PluginLoader {
return this.plugins.get(pluginId);
}
/** Ids of the plugins that ship with this install, loaded or replaced. */
bundledIds(): Set<string> {
return new Set([
...this.bundledVersions.keys(),
...this.list()
.filter((plugin) => plugin.source === "bundled")
.map((plugin) => plugin.id),
]);
}
/** Drops a stopped plugin from the list, so a new copy can load in its place. */
forget(pluginId: string): void {
const plugin = this.plugins.get(pluginId);
@@ -122,6 +142,12 @@ export class PluginLoader {
);
}
if (!isTermixCompatible(manifest.engine.termix, getLocalVersion())) {
throw new Error(
`Plugin ${manifest.id} needs Termix ${manifest.engine.termix}, but this is ${getLocalVersion()}`,
);
}
if (path.basename(dir) !== manifest.id) {
throw new Error(
`Plugin directory "${path.basename(dir)}" does not match manifest id "${manifest.id}"`,
@@ -141,6 +167,10 @@ export class PluginLoader {
);
}
if (this.plugins.has(manifest.id)) {
throw new Error(`another plugin already uses the id "${manifest.id}"`);
}
const overlap = [...this.plugins.values()].find((other) =>
prefixesOverlap(other.id, manifest.id),
);
@@ -175,16 +205,16 @@ export class PluginLoader {
}
/**
* Scans both roots. A user-installed plugin can never shadow a bundled one:
* the collision is rejected with an error rather than silently skipped, so
* dropping a folder with a bundled plugin's id into the data directory is visibly
* refused instead of quietly ignored.
* Scans both roots. A user plugin folder can never shadow a bundled one: the
* collision is rejected with an error rather than silently skipped.
*
* The check is on the directory name, which load() then requires to equal
* the manifest id, and it covers bundled folders that failed to load too.
* The one way to replace a bundled plugin is a .tmxplug signed by a trusted
* key with a higher version (see loadArtifact). That is how one official
* plugin ships an update without a core release.
*/
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
// to load must not leave its id free for a user plugin to take.
@@ -216,13 +246,16 @@ export class PluginLoader {
const dir = path.join(root, entry.name);
try {
if (isArtifact) {
loaded.push(await this.loadArtifact(dir, bundledIds));
const plugin = await this.loadArtifact(dir, bundledIds);
const index = loaded.findIndex((other) => other.id === plugin.id);
if (index >= 0) loaded[index] = plugin;
else loaded.push(plugin);
continue;
}
if (source === "user" && bundledIds.has(entry.name)) {
throw new Error(
`a bundled plugin already uses the id "${entry.name}"; a plugin in the data directory cannot replace it`,
`a bundled plugin already uses the id "${entry.name}"; only a signed .tmxplug with a higher version can replace it`,
);
}
@@ -236,6 +269,9 @@ export class PluginLoader {
}
const plugin = await this.load(dir, source);
if (source === "bundled") {
this.bundledVersions.set(plugin.id, plugin.manifest.version);
}
loaded.push(plugin);
} catch (error) {
pluginLogger.error(
@@ -255,6 +291,10 @@ export class PluginLoader {
* against a pinned key; with TERMIX_REQUIRE_SIGNED_PLUGINS=true it must
* also exist. The archive is unpacked fresh on every load, so the file
* stays the only source of truth.
*
* An artifact with a bundled plugin's id is an update to it. It replaces
* the bundled copy only when a trusted key signed it and its version is
* higher; if it then fails to load the bundled copy stays.
*/
async loadArtifact(
file: string,
@@ -262,6 +302,7 @@ export class PluginLoader {
): Promise<LoadedPlugin> {
const buffer = await fs.promises.readFile(file);
const sigFile = `${file}.sig`;
let signedBy: string | undefined;
if (fs.existsSync(sigFile)) {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
@@ -273,6 +314,7 @@ export class PluginLoader {
if (result.ok === false) {
throw new Error(`signature check failed: ${result.reason}`);
}
signedBy = result.keyId;
} else if (requireSignedPlugins()) {
throw new Error(
`TERMIX_REQUIRE_SIGNED_PLUGINS is on and ${path.basename(file)} has no .sig next to it`,
@@ -301,22 +343,47 @@ export class PluginLoader {
await fs.promises.readFile(getPluginManifestPath(staging), "utf8"),
);
const id = typeof raw?.id === "string" ? raw.id : "";
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
if (!PLUGIN_ID_PATTERN.test(id)) {
throw new Error(`${path.basename(file)} has no valid manifest id`);
}
if (bundledIds.has(id)) {
throw new Error(
`a bundled plugin already uses the id "${id}"; a plugin in the data directory cannot replace it`,
const isUpdate = bundledIds.has(id);
const replaced = isUpdate ? this.plugins.get(id) : undefined;
if (isUpdate) {
assertBundledUpdate(
id,
raw?.version,
signedBy,
this.bundledVersions.get(id) ?? replaced?.manifest.version,
);
}
if (this.plugins.has(id)) {
} else if (this.plugins.has(id)) {
throw new Error(`another plugin already uses the id "${id}"`);
}
if (replaced && replaced.state !== "loaded") {
throw new Error(`plugin ${id} is running; stop it before updating`);
}
const target = path.join(unpackedRoot, id);
await fs.promises.rm(target, { recursive: true, force: true });
await fs.promises.rename(staging, target);
return await this.load(target, "user");
if (replaced) this.plugins.delete(id);
try {
// A signed update keeps the bundled trust it replaces.
const plugin = await this.load(target, isUpdate ? "bundled" : "user");
plugin.artifact = file;
plugin.signedBy = signedBy;
if (replaced) {
pluginLogger.info(
`Plugin ${id} updated from ${replaced.manifest.version} to ${plugin.manifest.version}`,
{ operation: "plugin_load" },
);
}
return plugin;
} catch (error) {
if (replaced) this.plugins.set(id, replaced);
throw error;
}
} finally {
await fs.promises.rm(staging, { recursive: true, force: true });
}
@@ -427,9 +494,12 @@ export class PluginLoader {
plugin.state = "activating";
plugin.lastError = null;
const entry = pathToFileUrlString(
getPluginBackendEntry(plugin.dir, plugin.manifest),
);
const entryPath = getPluginBackendEntry(plugin.dir, plugin.manifest);
// Node caches ESM by URL, so a plugin replaced without a restart would
// otherwise keep running the old code.
const entry = `${pathToFileUrlString(entryPath)}?v=${encodeURIComponent(
`${plugin.manifest.version}-${fileStamp(entryPath)}`,
)}`;
try {
const imported = (await import(entry)) as {
@@ -639,27 +709,12 @@ export class PluginLoader {
this.options.onFailed?.(plugin);
}
/**
* Wraps a plugin callback so a throw is counted rather than escaping into
* whichever core call site invoked it.
*/
wrap<Args extends unknown[], Result>(
pluginId: string,
fn: (...args: Args) => Result | Promise<Result>,
): (...args: Args) => Promise<Result | undefined> {
return async (...args: Args) => {
try {
return await fn(...args);
} catch (error) {
await this.reportError(pluginId, error);
return undefined;
}
};
}
/** Clears the error budget and starts the plugin again. */
async retry(pluginId: string): Promise<void> {
const plugin = this.requirePlugin(pluginId);
if (plugin.state === "activating") {
throw new Error(`Plugin ${pluginId} is still activating`);
}
plugin.errorTimestamps = [];
plugin.lastError = null;
plugin.state = "loaded";
@@ -709,6 +764,43 @@ function withTimeout<T>(
});
}
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
/**
* Throws unless an artifact may replace the bundled plugin with the same id:
* a trusted key signed it and its version is strictly higher, so an old
* signed build can never be replayed as a downgrade.
*/
export function assertBundledUpdate(
id: string,
version: unknown,
signedBy: string | undefined,
bundledVersion: string | undefined,
): void {
if (!signedBy) {
throw new Error(
`a bundled plugin already uses the id "${id}"; only a signed update can replace it`,
);
}
if (typeof version !== "string" || !semver.valid(version)) {
throw new Error(`update for "${id}" has no valid version`);
}
if (bundledVersion && !semver.gt(version, bundledVersion)) {
throw new Error(
`update for "${id}" is ${version}, which is not newer than the bundled ${bundledVersion}`,
);
}
}
function fileStamp(filePath: string): string {
try {
const stat = fs.statSync(filePath);
return `${stat.size}-${Math.round(stat.mtimeMs)}`;
} catch {
return "0";
}
}
function pathToFileUrlString(filePath: string): string {
const resolved = path.resolve(filePath).replace(/\\/g, "/");
return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`;
+3
View File
@@ -11,6 +11,9 @@ export {
validateManifest,
parseManifest,
SUPPORTED_PLUGIN_API_VERSION,
PLUGIN_API_VERSION,
isApiCompatible,
isTermixCompatible,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
+20 -15
View File
@@ -20,6 +20,7 @@ import { pluginLogger } from "../utils/logger.js";
import type { DatabaseDialect } from "../database/db/dialect.js";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
import {
collectOwnedIndexes,
findUnownedTableWrites,
splitStatements,
} from "@termix/plugin-sdk/ddl";
@@ -28,9 +29,6 @@ import {
// migration exactly the way this runner does.
export { LEGACY_TABLE_OWNERS, splitStatements };
/** Dialect directory names, matching DatabaseDialect exactly. */
const DIALECTS: readonly DatabaseDialect[] = ["sqlite", "postgres", "mysql"];
const FILE_PATTERN = /^(\d{4})_([a-z0-9_]+)\.sql$/;
export interface PluginMigration {
@@ -120,7 +118,7 @@ export function readMigrations(
export function assertOwnedTables(
pluginId: string,
sql: string,
options: { bundled?: boolean } = {},
options: { bundled?: boolean; ownedIndexes?: ReadonlySet<string> } = {},
): void {
const legacy = new Set(
options.bundled
@@ -129,16 +127,19 @@ export function assertOwnedTables(
.map(([table]) => table)
: [],
);
const problems = findUnownedTableWrites(pluginId, sql, legacy).map(
(problem) => {
// Name the owner when the table is another plugin's legacy table.
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
return owner && owner !== pluginId
? `may not touch "${table}": it belongs to the "${owner}" plugin`
: problem;
},
);
const problems = findUnownedTableWrites(
pluginId,
sql,
legacy,
options.ownedIndexes,
).map((problem) => {
// Name the owner when the table is another plugin's legacy table.
const table = /writes to "([^"]+)"/.exec(problem)?.[1];
const owner = table ? LEGACY_TABLE_OWNERS[table] : undefined;
return owner && owner !== pluginId
? `may not touch "${table}": it belongs to the "${owner}" plugin`
: problem;
});
if (problems.length > 0) {
throw new Error(
`Plugin "${pluginId}" migration refused: ${problems.join("; ")}`,
@@ -197,9 +198,13 @@ export async function applyPluginMigrations(
if (pending.length === 0) return [];
const ownedIndexes = collectOwnedIndexes(
pluginId,
migrations.map((migration) => migration.sql),
);
const run = async () => {
for (const migration of pending) {
assertOwnedTables(pluginId, migration.sql, options);
assertOwnedTables(pluginId, migration.sql, { ...options, ownedIndexes });
const statements = splitStatements(migration.sql);
await runner.execute(statements);
await runner.record(pluginId, migration);
+6 -14
View File
@@ -1,19 +1,11 @@
/**
* A small main-thread service registry.
* A small in-process key/value registry for live objects plugins hand each
* other (a guest-link resolver, a host import normalizer). Services with
* versions and permissions go through service-registry.ts instead.
*
* This exists so a module can depend on a capability without depending on
* whichever module happens to provide it. Two uses today:
*
* - core registers the SSH connection pool as "ssh.transport" at boot, so
* everything that needs a pooled connection resolves a provider rather
* than importing the pool directly.
* - the session-sharing plugin publishes "sessions.sharing.guests", guest
* link resolution for the terminal, keyed by a token rather than a user.
*
* Deliberately NOT reachable from a worker plugin's ctx. Values here are live
* objects -- pools, session managers, sockets -- and handing one across the
* postMessage boundary is exactly what that boundary exists to prevent. Only
* in-process first-party plugins get a reference to this.
* ctx.registry only lets a plugin provide or revoke keys under its own id
* ("<pluginId>.something"); anyone may consume. Core also reads a few
* well-known per-plugin keys, such as "<pluginId>.hostImportNormalizer".
*
* `consume` returns undefined rather than throwing when nothing is registered.
* A provider can disappear when its plugin is disabled, and callers are
+52 -3
View File
@@ -23,6 +23,10 @@ export interface UserOwnedTable {
/** The legacy core name the table adopted, for older export files. */
legacyName?: string;
userColumn: string;
/** Other refUser columns, such as a grantee or the user who shared. */
otherUserColumns: string[];
/** refHost columns. */
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
definition: PluginTableDefinition;
@@ -39,6 +43,13 @@ export function listUserOwnedTables(): UserOwnedTable[] {
table: prefixedTableName(pluginId, definition.name),
legacyName: definition.adopts,
userColumn: columnName(user[0], user[1]),
otherUserColumns: entries
.filter(([, column]) => column.type === "refUser")
.slice(1)
.map(([property, column]) => columnName(property, column)),
hostColumns: entries
.filter(([, column]) => column.type === "refHost")
.map(([property, column]) => columnName(property, column)),
columns: entries
.filter(
([, column]) =>
@@ -130,10 +141,40 @@ export interface PluginRowsImport {
errors: string[];
}
/**
* Whether a row from an import file may be written as `userId`. The file is
* user input, so a reference to anyone else's user or host is not trusted:
* other user columns must point back at the row's own owner, and host columns
* at a host the importing user owns.
*/
async function rowReferencesAllowed(
owned: UserOwnedTable,
source: Row,
userId: string,
): Promise<boolean> {
const owner = source[owned.userColumn];
for (const column of owned.otherUserColumns) {
const value = source[column];
if (value !== null && value !== undefined && value !== owner) return false;
}
for (const column of owned.hostColumns) {
const value = source[column];
if (value === null || value === undefined) continue;
const rows = await selectRows(
sql`SELECT 1 FROM ${sql.identifier("ssh_data")} WHERE ${sql.identifier(
"id",
)} = ${value} AND ${sql.identifier("user_id")} = ${userId} LIMIT 1`,
);
if (rows.length === 0) return false;
}
return true;
}
/**
* Copies plugin rows from an import file into this server as `userId`. A row
* matching one the user already has is skipped. Files from before 2.9 carry
* the legacy table names, which are read too.
* matching one the user already has is skipped, and so is one that points at
* another user or at a host the importer does not own. Files from before 2.9
* carry the legacy table names, which are read too.
*/
export async function importUserPluginRows(
importDb: SqliteLike,
@@ -157,10 +198,18 @@ export async function importUserPluginRows(
const columns = owned.columns.filter(
(column) => column === owned.userColumn || source[column] !== undefined,
);
const owner = source[owned.userColumn];
const values = columns.map((column) =>
column === owned.userColumn ? userId : source[column],
column === owned.userColumn ||
(owned.otherUserColumns.includes(column) && source[column] === owner)
? userId
: source[column],
);
try {
if (!(await rowReferencesAllowed(owned, source, userId))) {
summary.skipped++;
continue;
}
const existing = await selectRows(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
+25 -1
View File
@@ -49,8 +49,13 @@ interface Route {
declared: readonly string[];
handler?: PluginWebSocketHandler;
rawHandler?: RawUpgradeHandler;
/** Sockets handed to rawHandler, destroyed when the route goes away. */
rawSockets?: Set<Duplex>;
}
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
const MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
/** Keyed by "<pluginId>:<path>". */
const routes = new Map<string, Route>();
@@ -177,6 +182,11 @@ async function handlePluginUpgrade(
if (route.rawHandler) {
const rawHandler = route.rawHandler;
const tracked = route.rawSockets;
if (tracked) {
tracked.add(socket);
socket.once("close", () => tracked.delete(socket));
}
asCaller(() => rawHandler(request, socket, head, userId));
return true;
}
@@ -270,7 +280,10 @@ export function registerPluginWsRoute(
handler,
});
if (options.public) auditPublicSocket(pluginId, normalized);
servers.set(routeKey, new WebSocketServer({ noServer: true }));
servers.set(
routeKey,
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
);
pluginLogger.info(`Mounted /plugin-ws/${pluginId}${normalized}`, {
operation: "plugin_ws_mount",
@@ -301,6 +314,7 @@ export function registerPluginWsUpgrade(
options,
declared,
rawHandler: handler,
rawSockets: new Set(),
});
if (options.public) auditPublicSocket(pluginId, normalized);
@@ -312,8 +326,18 @@ export function registerPluginWsUpgrade(
}
function disposeRoute(routeKey: string, pluginId: string, path: string): void {
const route = routes.get(routeKey);
routes.delete(routeKey);
for (const socket of route?.rawSockets ?? []) {
try {
socket.destroy();
} catch {
// Already gone.
}
}
route?.rawSockets?.clear();
const server = servers.get(routeKey);
if (server) {
servers.delete(routeKey);
+49 -1
View File
@@ -32,6 +32,8 @@ export interface RemotePlugin {
name: string;
version: string;
source: "bundled" | "user";
/** The server can send a signed .tmxplug for it. */
artifact?: boolean;
enabled: boolean;
active: boolean;
desktop: PluginDesktopMode;
@@ -52,6 +54,8 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
return record?.state === "enabled";
}
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
async function download(link: SyncLink, plugin: RemotePlugin): Promise<string> {
const response = await remoteFetch(
link,
@@ -83,6 +87,9 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
const repository = createCurrentPluginPermissionGrantRepository();
const local = getPluginRuntime().loader.get(plugin.id);
if (!local || local.source !== "user") return;
// Grants are only copied for code a trusted key signed. Anything else the
// desktop's own admin has to review, as with any plugin dropped on disk.
if (!local.signedBy) return;
const declared = new Set(local.manifest.capabilities);
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
const existing = await repository.listByPlugin(plugin.id);
@@ -117,7 +124,25 @@ async function alertOlderDesktop(plugin: RemotePlugin): Promise<void> {
});
}
async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
const key = `unsigned:${plugin.id}@${plugin.version}`;
if (alertedVersions.has(key)) return;
alertedVersions.add(key);
await sendCoreAlert({
title: "Review a feature from your server",
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
severity: "warning",
category: "termix.sync.unsigned_plugin",
dedupeKey: `sync:${key}`,
audience: "admins",
});
}
async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
// The id becomes a file name below, so it must be a real plugin id.
if (typeof plugin.id !== "string" || !PLUGIN_ID_PATTERN.test(plugin.id)) {
throw new Error("The server sent a plugin with an invalid id");
}
const { loader } = getPluginRuntime();
let local = loader.get(plugin.id);
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
@@ -131,7 +156,20 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
if (mode !== "mirror") return;
if (plugin.source === "bundled") {
if (!local || semver.gt(plugin.version, local.manifest.version)) {
const newer = !local || semver.gt(plugin.version, local.manifest.version);
if (newer && plugin.artifact && local?.state !== "activating") {
// A signed update of a bundled plugin. The loader checks the signature
// and that the version really is newer.
const wasEnabled = await isEnabledLocally(plugin.id);
if (local) await unloadPlugin(plugin.id);
try {
local = await installPluginArtifact(await download(link, plugin));
} catch (error) {
await alertOlderDesktop(plugin);
throw error;
}
if (wasEnabled) await setPluginEnabled(plugin.id, true);
} else if (newer) {
await alertOlderDesktop(plugin);
}
if (!local) return;
@@ -150,6 +188,16 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
}
}
await matchGrants(plugin);
// Unsigned code is only ever switched off from here, never on.
if (!local.signedBy) {
const enabledHere = await isEnabledLocally(plugin.id);
if (!plugin.enabled && enabledHere) {
await setPluginEnabled(plugin.id, false);
} else if (plugin.enabled && !enabledHere) {
await alertUnsigned(plugin);
}
return;
}
}
if (!local) return;
+5 -4
View File
@@ -493,6 +493,8 @@ router.get(
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
// A bundled plugin updated from a signed .tmxplug can be mirrored.
artifact: !!plugin.artifact,
enabled: records.get(plugin.id)?.state === "enabled",
active: plugin.state === "active",
desktop: plugin.manifest.desktop ?? "mirror",
@@ -517,7 +519,7 @@ router.get(
* /sync/v2/plugins/{id}/package:
* get:
* summary: Download an installed plugin so a linked desktop can install it
* description: Only for plugins installed on this server, not bundled ones. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
* description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
* tags:
* - Sync
* parameters:
@@ -541,11 +543,10 @@ router.get(
const plugin = (await listPluginsForDesktop()).find(
(candidate) => candidate.id === pluginId,
);
if (!plugin || plugin.source !== "user") {
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) {
return res.status(404).json({ error: "Plugin not found" });
}
const { getPluginsDir } = await import("../../plugins/paths.js");
const artifact = path.join(getPluginsDir(), `${pluginId}.tmxplug`);
const artifact = plugin.artifact ?? "";
res.setHeader("Content-Type", "application/gzip");
if (fs.existsSync(artifact)) {
const sig = `${artifact}.sig`;
+4 -4
View File
@@ -43,7 +43,7 @@ export function fakeFactory(state: AuthState) {
findById: async (id: string) => state.users.get(id) ?? null,
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
findByOidcIdentifier: async (identifier: string) =>
findByExternalIdentifier: async (identifier: string) =>
[...state.users.values()].find(
(u) => u.oidcIdentifier === identifier,
) ?? null,
@@ -206,10 +206,10 @@ export function fakeAuthManager(state: AuthState) {
return userId ? { userId, pendingTOTP: true } : null;
}),
authenticateUser: vi.fn(async () => true),
authenticateOIDCUser: vi.fn(async () => true),
revokeSessionsByOidc: vi.fn(async () => 1),
authenticateExternalUser: vi.fn(async () => true),
revokeSessionsByExternalSession: vi.fn(async () => 1),
unlockWithSystemKey: vi.fn(async () => true),
registerOIDCUser: vi.fn(async () => {}),
registerExternalUser: vi.fn(async () => {}),
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
state.trusted.has(`${userId}:${fingerprint}`),
),
@@ -1,117 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { PluginInstallCountRepository } from "../../../database/repositories/plugin-install-count-repository.js";
describe("PluginInstallCountRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<PluginInstallCountRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
return new PluginInstallCountRepository(context, onWrite);
}
it("inserts a count on first upsert", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const created = await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-official",
count: 42,
source: "github-releases",
});
expect(created).toMatchObject({
pluginId: "plugin-a",
registryId: "registry-official",
count: 42,
source: "github-releases",
});
expect(writes).toBe(1);
});
it("updates the existing row on a repeat upsert for the same plugin/registry pair", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
const first = await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-official",
count: 10,
});
const second = await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-official",
count: 25,
});
expect(second.id).toBe(first.id);
expect(second.count).toBe(25);
expect(writes).toBe(2);
const rows = await repo.listByPlugin("plugin-a");
expect(rows).toHaveLength(1);
});
it("keeps counts from different registries for the same plugin separate", async () => {
const repo = await createRepository();
await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-official",
count: 10,
});
await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-community",
count: 5,
});
const rows = await repo.listByPlugin("plugin-a");
expect(rows.map((r) => r.registryId).sort()).toEqual([
"registry-community",
"registry-official",
]);
expect(
await repo.findByPluginAndRegistry("plugin-a", "registry-official"),
).toMatchObject({ count: 10 });
expect(
await repo.findByPluginAndRegistry("plugin-a", "missing-registry"),
).toBeNull();
});
it("deletes all counts for a plugin", async () => {
const repo = await createRepository();
await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-official",
count: 10,
});
await repo.upsert({
pluginId: "plugin-a",
registryId: "registry-community",
count: 5,
});
expect(await repo.deleteByPlugin("plugin-a")).toBe(2);
expect(await repo.listByPlugin("plugin-a")).toEqual([]);
expect(await repo.deleteByPlugin("plugin-a")).toBe(0);
});
});
@@ -1,128 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { PluginRegistryRepository } from "../../../database/repositories/plugin-registry-repository.js";
describe("PluginRegistryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<PluginRegistryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
return new PluginRegistryRepository(context, onWrite);
}
it("creates, finds, and lists registries", async () => {
const repo = await createRepository();
await repo.create({
id: "registry-official",
name: "Official Registry",
url: "https://plugins.termix.site/index.json",
kind: "official",
});
await repo.create({
id: "registry-custom",
name: "Custom Registry",
url: "https://example.com/index.json",
});
const found = await repo.findById("registry-official");
expect(found).toMatchObject({
id: "registry-official",
name: "Official Registry",
kind: "official",
enabled: true,
});
const custom = await repo.findById("registry-custom");
expect(custom).toMatchObject({ kind: "community", enabled: true });
const all = await repo.listAll();
expect(all.map((r) => r.id).sort()).toEqual([
"registry-custom",
"registry-official",
]);
expect(await repo.findById("missing")).toBeNull();
});
it("updates only the provided fields", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
await repo.create({
id: "registry-custom",
name: "Custom Registry",
url: "https://example.com/index.json",
});
expect(writes).toBe(1);
const updated = await repo.update("registry-custom", {
enabled: false,
lastCheckedAt: "2026-01-01T00:00:00.000Z",
lastIndexHash: "abc123",
});
expect(updated).toMatchObject({
id: "registry-custom",
name: "Custom Registry",
enabled: false,
lastCheckedAt: "2026-01-01T00:00:00.000Z",
lastIndexHash: "abc123",
});
expect(writes).toBe(2);
expect(await repo.update("missing", { enabled: false })).toBeNull();
expect(writes).toBe(2);
});
it("deletes a registry and reports whether a row was removed", async () => {
let writes = 0;
const repo = await createRepository(() => {
writes += 1;
});
await repo.create({
id: "registry-custom",
name: "Custom Registry",
url: "https://example.com/index.json",
});
expect(writes).toBe(1);
expect(await repo.delete("missing")).toBe(false);
expect(writes).toBe(1);
expect(await repo.delete("registry-custom")).toBe(true);
expect(writes).toBe(2);
expect(await repo.findById("registry-custom")).toBeNull();
});
it("deletes all registries and returns the count removed", async () => {
const repo = await createRepository();
await repo.create({
id: "registry-a",
name: "A",
url: "https://a.example.com/index.json",
});
await repo.create({
id: "registry-b",
name: "B",
url: "https://b.example.com/index.json",
});
expect(await repo.deleteAll()).toBe(2);
expect(await repo.listAll()).toEqual([]);
expect(await repo.deleteAll()).toBe(0);
});
});
@@ -67,7 +67,7 @@ describe("UserRepository and SessionRepository", () => {
oidcIdentifier: "oidc:admin",
});
expect(updated?.oidcIdentifier).toBe("oidc:admin");
expect((await repo.users.findByOidcIdentifier("oidc:admin"))?.id).toBe(
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
"user-1",
);
@@ -40,6 +40,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
},
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async (userId: string) =>
h.identities.filter((row) => row.userId === userId),
moveIdentities: async (from: string, to: string) => {
for (const row of h.identities) if (row.userId === from) row.userId = to;
},
@@ -68,8 +70,8 @@ vi.mock("../../../utils/logger.js", () => {
return { authLogger: log };
});
const { registerUserOidcAccountRoutes } =
await import("../../../database/routes/user-oidc-account-routes.js");
const { registerUserExternalAccountRoutes } =
await import("../../../database/routes/user-external-account-routes.js");
let server: http.Server;
let base: string;
@@ -114,7 +116,7 @@ beforeEach(async () => {
const router = express.Router();
router.use(express.json());
registerUserOidcAccountRoutes(router, {
registerUserExternalAccountRoutes(router, {
authenticateJWT: (req, _res, next) => {
(req as unknown as { userId: string }).userId =
req.header("x-user") ?? "";
@@ -164,6 +166,24 @@ describe("linking an external account to a password account", () => {
expect(h.deleted).toEqual(["sso"]);
});
it("takes the new path and field name", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.identities[0].userId).toBe("local");
});
it("refuses a target that already signs in externally", async () => {
h.identities.push({ userId: "local", providerId: "ldap:4", subject: "x" });
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(400);
});
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
@@ -200,6 +220,18 @@ describe("unlinking", () => {
expect(h.identities).toEqual([]);
});
it("takes the new path", async () => {
await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-external-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "sso",
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateDefaultsJson } from "../../../database/routes/user-preferences.js";
describe("user connection defaults validation", () => {
it("accepts JSON objects", () => {
expect(validateDefaultsJson('{"fontSize":16}')).toBe(true);
expect(validateDefaultsJson("{}")).toBe(true);
});
it("rejects malformed JSON and non-object values", () => {
expect(validateDefaultsJson("{")).toBe(false);
expect(validateDefaultsJson("null")).toBe(false);
expect(validateDefaultsJson("[]")).toBe(false);
});
it("rejects payloads larger than 32 KiB", () => {
expect(
validateDefaultsJson(JSON.stringify({ value: "x".repeat(32_768) })),
).toBe(false);
});
});
@@ -165,6 +165,7 @@ function startBackend(
describe("local-files transfer target resolution", () => {
const resolve = localFiles.createTargetResolver({
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
apiPath: "/plugin-api/file-manager",
getLinkedServer: () => ({
serverUrl: "https://termix.example.com/",
token: "remote-jwt",
@@ -1149,3 +1150,17 @@ describe("local-files upload boundary", () => {
expect(result.error).toMatch(/Unknown transfer origin/);
});
});
describe("local-files transfer API path", () => {
it("accepts only a /plugin-api/<id> path from the plugin", () => {
const set = (
localFiles as unknown as {
setTransferApiPath: (path: unknown) => void;
}
).setTransferApiPath;
expect(() => set("/plugin-api/file-manager")).not.toThrow();
for (const bad of ["/users", "/plugin-api/../users", "https://x", 3]) {
expect(() => set(bad)).toThrow(/Invalid transfer API path/);
}
});
});
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
pickResolvedUsername,
pickResolvedPassword,
expandOidcUsername,
expandExternalUsername,
} from "../../hosts/credential-username.js";
describe("pickResolvedUsername", () => {
@@ -52,14 +52,14 @@ describe("pickResolvedPassword", () => {
});
});
describe("expandOidcUsername", () => {
describe("expandExternalUsername", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns the username unchanged when it has no placeholder", async () => {
expect(await expandOidcUsername("alice", "user-1")).toBe("alice");
expect(await expandOidcUsername(undefined, "user-1")).toBeUndefined();
expect(await expandExternalUsername("alice", "user-1")).toBe("alice");
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
});
it("expands the placeholder with the user's OIDC identifier", async () => {
@@ -69,9 +69,13 @@ describe("expandOidcUsername", () => {
}),
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
expect(await expand("$external.username", "user-1")).toBe("jdoe");
expect(
await expand("$external.username-$oidc.preferred_username", "user-1"),
).toBe("jdoe-jdoe");
});
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
@@ -81,7 +85,7 @@ describe("expandOidcUsername", () => {
}),
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
@@ -95,7 +99,7 @@ describe("expandOidcUsername", () => {
},
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
@@ -117,7 +121,7 @@ describe("expandOidcUsername", () => {
}),
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
});
@@ -137,7 +141,7 @@ describe("expandOidcUsername", () => {
}),
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
@@ -159,7 +163,7 @@ describe("expandOidcUsername", () => {
}),
}));
const { expandOidcUsername: expand } =
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
+42 -2
View File
@@ -149,7 +149,7 @@ describe("capability gate", () => {
describe("ctx.asUser", () => {
it("makes the named user the actor and audits the switch", async () => {
grants.set("sample-plugin", ["kv:own"]);
const ctx = context(["kv:own"]);
const ctx = context(["kv:own", "users:impersonate"]);
let seen: string | undefined;
await ctx.asUser("user-7", async () => {
@@ -169,7 +169,7 @@ describe("ctx.asUser", () => {
});
it("restores the previous actor afterwards", async () => {
const ctx = context([]);
const ctx = context(["users:impersonate"]);
await runAsActor("outer-user", "request", async () => {
await ctx.asUser("inner-user", async () => {
@@ -186,6 +186,46 @@ describe("ctx.asUser", () => {
/without a user id/,
);
});
it("refuses without users:impersonate", async () => {
const ctx = context(["kv:own"]);
await expect(ctx.asUser("user-7", async () => {})).rejects.toThrow(
/users:impersonate/,
);
});
});
describe("ctx.registry namespacing", () => {
it("lets a plugin provide and revoke its own keys", () => {
const ctx = context([]);
const value = { ok: true };
ctx.registry.provide("sample-plugin.thing", value);
expect(ctx.registry.consume("sample-plugin.thing")).toBe(value);
expect(ctx.registry.revoke("sample-plugin.thing", value)).toBe(true);
});
it("refuses a key outside the plugin's namespace", () => {
const ctx = context([]);
expect(() => ctx.registry.provide("other.thing", {})).toThrow(
/registry keys under "sample-plugin\."/,
);
expect(() => ctx.registry.revoke("other.thing")).toThrow(
/registry keys under/,
);
});
});
describe("ctx.services.get", () => {
it("refuses a service the manifest does not require", () => {
const ctx = context([]);
expect(() => ctx.services.get("someone.else")).toThrow(
/not declared in the manifest's requires/,
);
});
});
describe("ctx.events namespacing", () => {
@@ -140,7 +140,7 @@ function setup() {
kind: "redirect",
external: true,
describe: async () => [
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
{ id: "3", label: "Corp", enabled: true, type: "oidc", autoStart: true },
],
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
callback: async () => ({
@@ -161,7 +161,9 @@ function setup() {
labelKey: "signIn",
kind: "form",
external: true,
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
describe: async () => [
{ id: "4", label: "Directory", enabled: true, autoStart: true },
],
verify: async (request) => {
if (request.body.password !== "hunter2") {
await auth.loginRateLimit.recordFailure(
@@ -303,7 +305,7 @@ describe("the other ctx.auth helpers", () => {
it("revokes sessions by logout claims and audits it", async () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
ssoProviderId: 3,
sub: null,
sid: "sid-1",
@@ -352,6 +354,17 @@ describe("a disabled plugin", () => {
]);
});
it("passes autoStart through for redirect methods only", async () => {
setup();
const methods = await listPublicLoginMethods();
expect(methods.find((m) => m.id === "corp-sso")?.instances).toEqual([
{ id: "3", label: "Corp", autoStart: true },
]);
expect(methods.find((m) => m.id === "corp-dir")?.instances).toEqual([
{ id: "4", label: "Directory" },
]);
});
it("hides its buttons and leaves no route that signs anyone in", async () => {
const { bag } = setup();
await bag.disposeAll();
+8 -1
View File
@@ -190,10 +190,17 @@ describe("ctx.db capability", () => {
});
});
it("hands back read-only refs to the core tables a plugin may join", async () => {
it("refuses core refs with only db:own", async () => {
grants.set("demo", ["db:own"]);
const { ctx } = contextFor("demo", ["db:own"]);
await expect(ctx.db.refs()).rejects.toThrow(/db:core-refs/);
});
it("hands back refs to the core tables a plugin may join", async () => {
grants.set("demo", ["db:core-refs"]);
const { ctx } = contextFor("demo", ["db:core-refs"]);
const refs = (await ctx.db.refs()) as Record<string, unknown>;
expect(Object.keys(refs).sort()).toEqual([
+13 -8
View File
@@ -122,19 +122,24 @@ describe("ctx.desktop.openIsolatedWindow", () => {
});
});
describe("ctx.desktop.launchNativeRdp", () => {
const request = { host: "win.example.test", port: 3389, username: "bob" };
describe("ctx.desktop.launchExternalClient", () => {
const request = {
protocol: "rdp",
host: "win.example.test",
port: 3389,
username: "bob",
};
it("refuses without desktop:window", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
/desktop:window/,
);
expect(bridge.requestFromElectronMain).not.toHaveBeenCalled();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_launch_native_rdp",
action: "plugin_desktop_launch_external_client",
success: false,
});
});
@@ -144,7 +149,7 @@ describe("ctx.desktop.launchNativeRdp", () => {
const { ctx } = contextFor("demo", ["desktop:window"]);
expect(ctx.desktop.available()).toBe(false);
await expect(ctx.desktop.launchNativeRdp(request)).rejects.toThrow(
await expect(ctx.desktop.launchExternalClient(request)).rejects.toThrow(
/desktop app/,
);
});
@@ -155,15 +160,15 @@ describe("ctx.desktop.launchNativeRdp", () => {
const { ctx } = contextFor("demo", ["desktop:window"]);
expect(ctx.desktop.available()).toBe(true);
await expect(ctx.desktop.launchNativeRdp(request)).resolves.toEqual({
await expect(ctx.desktop.launchExternalClient(request)).resolves.toEqual({
success: true,
});
expect(bridge.requestFromElectronMain).toHaveBeenCalledWith(
"launch-native-rdp",
"launch-external-client",
request,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_desktop_launch_native_rdp",
action: "plugin_desktop_launch_external_client",
success: true,
});
});
+25
View File
@@ -401,3 +401,28 @@ describe("ctx.http streaming", () => {
expect(rest).toContain("data: done");
});
});
describe("public route rate limit", () => {
afterEach(() => {
http.resetPublicRateLimits();
delete process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT;
});
it("allows up to the limit per address and plugin, then refuses", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "3";
const now = 1_000;
const hits = [1, 2, 3, 4].map(() =>
http.overPublicRateLimit("demo", "1.2.3.4", now),
);
expect(hits).toEqual([false, false, false, true]);
expect(http.overPublicRateLimit("demo", "5.6.7.8", now)).toBe(false);
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
});
it("starts a new window after a minute", () => {
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
expect(http.overPublicRateLimit("demo", "ip", 10)).toBe(true);
expect(http.overPublicRateLimit("demo", "ip", 60_001)).toBe(false);
});
});
-13
View File
@@ -472,19 +472,6 @@ describe("PluginLoader error budget", () => {
expect(loader.get("sample-plugin")?.state).toBe("active");
});
it("wrap() counts a throwing callback instead of letting it escape", async () => {
const loader = await failing(2);
const wrapped = loader.wrap("sample-plugin", () => {
throw new Error("callback failed");
});
await expect(wrapped()).resolves.toBeUndefined();
await wrapped();
expect(loader.get("sample-plugin")?.state).toBe("failed");
});
it("retry clears the budget and starts the plugin again", async () => {
const loader = await failing(1);
+30 -1
View File
@@ -11,6 +11,7 @@ import {
parseManifest,
validateManifest,
SUPPORTED_PLUGIN_API_VERSION,
isTermixCompatible,
} from "../../plugins/manifest.js";
function base(overrides: Record<string, unknown> = {}) {
@@ -91,7 +92,35 @@ describe("manifest v2 validation", () => {
);
expect(manifest).toBeUndefined();
expect(errors.join()).toMatch(/SDK API version/);
expect(errors.join()).toMatch(/needs plugin API 2/);
});
it("accepts an api range this build satisfies", () => {
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
expect(
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
).toEqual([]);
}
});
it("refuses an api range needing a newer minor", () => {
const { errors } = parseManifest(
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
);
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
});
it("refuses an engine that is not a semver range", () => {
expect(
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
});
it("checks engine.termix against the core version", () => {
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
expect(isTermixCompatible(">=3.0.0", "2.9.1")).toBe(false);
expect(isTermixCompatible(">=3.0.0", null)).toBe(true);
});
it("fills in the entry point defaults", () => {
@@ -7,6 +7,7 @@
* group was declared twice.
*/
import type { PluginLoader } from "../../plugins/loader.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
@@ -113,7 +114,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { createFixturePlugin } = await import("./fixture-plugin.js");
const {
getPermissionCatalog,
@@ -433,7 +433,7 @@ describe("shipped manifests keep their permission ids", () => {
describe("role defaults apply exactly once", () => {
let fixture: Fixture;
let loader: InstanceType<typeof PluginLoader>;
let loader: PluginLoader;
beforeEach(() => {
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
@@ -128,7 +128,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: CONSUMER_ID,
capabilities: ["kv:own"],
capabilities: ["kv:own", "users:impersonate"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requiresSecret },
});
@@ -81,7 +81,13 @@ function contextFor(pluginId: string, extra: Record<string, unknown> = {}) {
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["hosts:read", "ssh:connect", "credentials:use"],
capabilities: [
"hosts:read",
"ssh:connect",
"credentials:use",
"users:impersonate",
],
requires: [{ service: "sample.echo", versionRange: "^1.0.0" }],
...extra,
} as never,
handle,
@@ -11,7 +11,10 @@ import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findUnownedTableWrites } from "@termix/plugin-sdk/ddl";
import {
collectOwnedIndexes,
findUnownedTableWrites,
} from "@termix/plugin-sdk/ddl";
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
const state = vi.hoisted(() => ({
@@ -74,6 +77,40 @@ describe("the migration checker", () => {
["a truncate", "TRUNCATE TABLE users"],
["an unlogged table", "CREATE UNLOGGED TABLE users (id int)"],
["a CTE write", "WITH x AS (SELECT 1) DELETE FROM users"],
[
"a Postgres dollar-quoted string hiding a statement",
"SELECT $$'$$; UPDATE users SET is_admin = true; SELECT $$'$$",
],
[
"a MySQL backslash escape hiding a statement",
"SELECT 'a\\''; UPDATE users SET is_admin = 1; SELECT ''",
],
["dropping a core index by name", "DROP INDEX idx_sessions_user_id"],
["altering a core index by name", "ALTER INDEX users_pkey RENAME TO x"],
["dropping a core sequence", "DROP SEQUENCE users_id_seq"],
["a cascade", "DROP TABLE p_foo_a CASCADE"],
[
"a MySQL multi-table update",
"UPDATE p_foo_a JOIN users ON 1 = 1 SET users.is_admin = 1",
],
["a MySQL comma update", "UPDATE p_foo_a, users SET users.is_admin = 1"],
[
"a MySQL multi-table delete",
"DELETE p_foo_a, users FROM p_foo_a JOIN users",
],
[
"a delete with USING",
"DELETE FROM p_foo_a, users USING p_foo_a JOIN users",
],
["SELECT INTO", "SELECT * INTO users_copy FROM users"],
["writing a file", "SELECT 1 INTO OUTFILE '/tmp/x'"],
["reading a server file", "SELECT pg_read_file('/etc/passwd')"],
["changing a server setting", "SELECT set_config('x', 'y', false)"],
[
"running a string as SQL",
"SELECT query_to_xml('DELETE FROM users', true, true, '')",
],
["setval on a core sequence", "SELECT setval('users_id_seq', 1)"],
])("refuses %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).not.toEqual([]);
});
@@ -90,10 +127,34 @@ describe("the migration checker", () => {
"a quoted name in a string",
"INSERT INTO p_foo_a (x) VALUES ('DROP TABLE users')",
],
[
"dropping an index it created in the same file",
"CREATE INDEX idx_foo_a ON p_foo_a (id); DROP INDEX idx_foo_a",
],
["dropping a prefixed index", "DROP INDEX IF EXISTS p_foo_a_idx"],
[
"setval on its own sequence",
"SELECT setval(pg_get_serial_sequence('p_foo_a', 'id'), 1)",
],
["its own sequence", "CREATE SEQUENCE IF NOT EXISTS p_foo_seq"],
])("allows %s", (_label, sql) => {
expect(findUnownedTableWrites("foo", sql)).toEqual([]);
});
it("allows dropping an index an earlier migration created", () => {
const owned = collectOwnedIndexes("foo", [
"CREATE INDEX idx_foo_a ON p_foo_a (id)",
"CREATE INDEX idx_core ON users (id)",
]);
expect([...owned]).toEqual(["idx_foo_a"]);
expect(
findUnownedTableWrites("foo", "DROP INDEX idx_foo_a", new Set(), owned),
).toEqual([]);
expect(
findUnownedTableWrites("foo", "DROP INDEX idx_core", new Set(), owned),
).not.toEqual([]);
});
it("passes every migration a bundled plugin ships", () => {
const plugins = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
@@ -121,7 +121,7 @@ function consumerFixture(
): Fixture {
return createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
capabilities: ["kv:own", "users:impersonate"],
backendSource: CONSUMER_SOURCE,
manifestOverrides: { category: "Infrastructure", requires },
});
@@ -219,6 +219,7 @@ describe("plugin service contracts", () => {
it("activates anyway when an unsatisfied requirement is optional", async () => {
consumer = consumerFixture([
{ service: SERVICE, versionRange: "^1.2.0", optional: true },
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
]);
@@ -69,10 +69,11 @@ async function writeArtifact(
dir: string,
id: string,
sig: "valid" | "wrong-key" | "none",
version = "1.0.0",
): Promise<string> {
const build = tempRoot("termix-build-");
createFixturePlugin({ id, root: build });
const file = path.join(dir, `${id}-1.0.0.tmxplug`);
createFixturePlugin({ id, root: build, manifestOverrides: { version } });
const file = path.join(dir, `${id}-${version}.tmxplug`);
await tar.c({ gzip: true, file, cwd: path.join(build, id) }, [
"manifest.json",
"backend",
@@ -146,13 +147,57 @@ describe("signed plugins gate on", () => {
expect(await loadedIds()).toEqual(["bundled-one", "signed-one"]);
});
it("refuses a signed archive that takes a bundled plugin's id", async () => {
it("refuses a signed archive of a bundled id that is not newer", async () => {
const { userDir } = setup();
process.env.TERMIX_REQUIRE_SIGNED_PLUGINS = "true";
await writeArtifact(userDir, "bundled-one", "valid");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
expect(loader.get("bundled-one")?.source).toBe("bundled");
expect(loader.get("bundled-one")?.artifact).toBeUndefined();
});
});
describe("updating a bundled plugin", () => {
it("loads a signed, newer archive in place of the bundled copy", async () => {
const { userDir } = setup();
const file = await writeArtifact(userDir, "bundled-one", "valid", "1.0.1");
const loader = new PluginLoader();
const loaded = await loader.loadAll();
expect(loaded.map((plugin) => plugin.id)).toEqual(["bundled-one"]);
const plugin = loader.get("bundled-one");
expect(plugin?.manifest.version).toBe("1.0.1");
expect(plugin?.source).toBe("bundled");
expect(plugin?.artifact).toBe(file);
expect(plugin?.signedBy).toBe("test");
});
it("keeps the bundled copy for an unsigned newer archive", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "bundled-one", "none", "2.0.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("keeps the bundled copy for a signed older archive", async () => {
const { userDir } = setup();
await writeArtifact(userDir, "bundled-one", "valid", "0.9.0");
const loader = new PluginLoader();
await loader.loadAll();
expect(loader.get("bundled-one")?.manifest.version).toBe("1.0.0");
});
it("refuses a downgrade after the bundled copy was unloaded", async () => {
const { userDir } = setup();
const loader = new PluginLoader();
await loader.loadAll();
loader.forget("bundled-one");
const file = await writeArtifact(userDir, "bundled-one", "valid", "0.5.0");
await expect(
loader.loadArtifact(file, loader.bundledIds()),
).rejects.toThrow(/not newer than the bundled 1\.0\.0/);
});
});
@@ -11,6 +11,7 @@ import {
defineTable,
encryptedText,
id,
refHost,
refUser,
text,
varchar,
@@ -134,4 +135,37 @@ describe("plugin user data", () => {
.get(),
).toEqual({ user_id: "u1", note: "x" });
});
it("skips imported rows that point at another user or host", async () => {
sqlite.exec("DROP TABLE ssh_data");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT)");
sqlite.exec(
"INSERT INTO ssh_data (id, user_id) VALUES (1, 'u2'), (2, 'u1')",
);
const grants = defineTable("grant", {
id: id(),
ownerId: refUser(),
granteeId: refUser(),
hostId: refHost(),
});
for (const statement of createTableSql("sqlite", "fixture", grants)) {
sqlite.exec(statement);
}
registerTable("fixture", grants);
const file = new Database(":memory:");
file.exec(
"CREATE TABLE p_fixture_grant (id INTEGER PRIMARY KEY, owner_id TEXT, grantee_id TEXT, host_id INTEGER)",
);
file.exec(`INSERT INTO p_fixture_grant (owner_id, grantee_id, host_id) VALUES
('old', 'old', 1), ('old', 'u1', 1), ('old', 'old', 2)`);
const result = await importUserPluginRows(file, "u2");
expect(result).toMatchObject({ imported: 1, skipped: 2 });
expect(
sqlite
.prepare("SELECT owner_id, grantee_id, host_id FROM p_fixture_grant")
.all(),
).toEqual([{ owner_id: "u2", grantee_id: "u2", host_id: 1 }]);
});
});
@@ -0,0 +1,79 @@
/**
* The silent sign-in default moving from core settings into the sso plugin's
* admin settings.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "sso" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.plugin.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
beforeEach(() => {
state.core = new Map();
state.plugin = new Map();
state.pluginInstalled = true;
});
describe("runSsoSettingsMigration", () => {
it("moves the core value into the sso admin setting", async () => {
state.core.set("oidc_silent_login_default", "true");
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
expect(state.plugin.get("silentLoginDefault")).toBe("true");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("keeps a value already in plugin settings", async () => {
state.core.set("oidc_silent_login_default", "true");
state.plugin.set("silentLoginDefault", "false");
await runSsoSettingsMigration();
expect(state.plugin.get("silentLoginDefault")).toBe("false");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("does nothing twice or without the plugin", async () => {
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
state.core.set("oidc_silent_login_default", "true");
state.pluginInstalled = false;
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
expect(state.core.get("oidc_silent_login_default")).toBe("true");
});
});
@@ -99,7 +99,7 @@ function sessionIds(): string[] {
.map((row) => String(row.id));
}
describe("AuthManager.revokeSessionsByOidc", () => {
describe("AuthManager.revokeSessionsByExternalSession", () => {
beforeAll(() => {
mocks.sqlite!.exec(`
CREATE TABLE sessions (
@@ -147,7 +147,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
});
await expect(
authManager.revokeSessionsByOidc({
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
sub: "subject-1",
sid: "session-1",
@@ -181,7 +181,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
});
await expect(
authManager.revokeSessionsByOidc({
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
sub: "subject-1",
}),
@@ -192,7 +192,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
it("does not persist when no session matches", async () => {
await expect(
authManager.revokeSessionsByOidc({
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
sid: "missing",
}),
@@ -212,7 +212,7 @@ describe("AuthManager.revokeSessionsByOidc", () => {
mocks.saveDatabase.mockRejectedValueOnce(new Error("disk full"));
await expect(
authManager.revokeSessionsByOidc({
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
sid: "session-1",
}),
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest";
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
import { isExternalTokenCallback } from "../../utils/external-login-callback.js";
describe("isOidcTokenCallback", () => {
describe("isExternalTokenCallback", () => {
it.each([
"http://localhost:17850/oidc-callback",
"http://127.0.0.1:17850/oidc-callback",
"termix-mobile://oidc-callback",
])("recognizes app callback %s", (url) => {
expect(isOidcTokenCallback(url)).toBe(true);
expect(isExternalTokenCallback(url)).toBe(true);
});
it.each([
@@ -15,6 +15,6 @@ describe("isOidcTokenCallback", () => {
"http://example.com:17850/oidc-callback",
"http://localhost:17850/other",
])("rejects non-app callback %s", (url) => {
expect(isOidcTokenCallback(url)).toBe(false);
expect(isExternalTokenCallback(url)).toBe(false);
});
});
@@ -1,146 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import express from "express";
import http from "http";
import type { AddressInfo } from "net";
import { listenOnServicePort } from "../../utils/service-listen.js";
import { Logger } from "../../utils/logger.js";
function testLogger() {
const logger = new Logger("TEST", "🧪", "#000000");
return {
logger,
error: vi.spyOn(logger, "error").mockImplementation(() => {}),
};
}
function occupyPort(): Promise<{ port: number; release: () => void }> {
return new Promise((resolve) => {
const blocker = http.createServer();
blocker.listen(0, "127.0.0.1", () => {
resolve({
port: (blocker.address() as AddressInfo).port,
release: () => blocker.close(),
});
});
});
}
const servers: http.Server[] = [];
afterEach(() => {
while (servers.length) servers.pop()?.close();
vi.restoreAllMocks();
});
describe("listenOnServicePort", () => {
it("starts the service and reports listening on a free port", async () => {
const { logger, error } = testLogger();
const onListening = vi.fn();
const server = listenOnServicePort({
app: express(),
port: 0,
logger,
serviceName: "test",
onListening,
});
servers.push(server);
await vi.waitFor(() => expect(onListening).toHaveBeenCalledOnce());
expect(server.listening).toBe(true);
expect(error).not.toHaveBeenCalled();
});
// The bug this helper exists for: express's app.listen(port, host, cb)
// registers cb as the server's error handler too, so a port conflict
// invoked the "service started" callback and emitted nothing at all.
it("does not report the service as started when the port is taken", async () => {
const { port, release } = await occupyPort();
const { logger } = testLogger();
const onListening = vi.fn();
const exit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const server = listenOnServicePort({
app: express(),
port,
logger,
serviceName: "test",
onListening,
});
servers.push(server);
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
expect(onListening).not.toHaveBeenCalled();
release();
});
it("names the conflicting port so the desktop can surface it", async () => {
const { port, release } = await occupyPort();
const { logger, error } = testLogger();
vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
servers.push(
listenOnServicePort({
app: express(),
port,
logger,
serviceName: "metrics",
}),
);
await vi.waitFor(() => expect(error).toHaveBeenCalled());
const [message, , context] = error.mock.calls[0];
expect(message).toContain(String(port));
expect(context).toMatchObject({
operation: "service_port_conflict",
port,
service: "metrics",
});
release();
});
it("leaves any other listen error to the process-level handlers", async () => {
const { logger } = testLogger();
const exit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const server = listenOnServicePort({
app: express(),
port: 0,
logger,
serviceName: "test",
});
servers.push(server);
const notPortConflict = Object.assign(new Error("boom"), { code: "EPERM" });
expect(() => server.emit("error", notPortConflict)).toThrow("boom");
expect(exit).not.toHaveBeenCalled();
});
});
// express's app.listen() swallows a bind failure by design (see
// service-listen.ts), so no service may reach for it again.
describe("backend services", () => {
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) {
return entry === "tests" ? [] : sourceFiles(full);
}
return full.endsWith(".ts") ? [full] : [];
});
}
it("never bind a port with express's app.listen()", () => {
const offenders = sourceFiles(path.resolve("src/backend"))
// service-listen.ts names the call only to document why it is banned.
.filter((file) => !file.endsWith(path.join("utils", "service-listen.ts")))
.filter((file) => /\bapp\.listen\(/.test(readFileSync(file, "utf8")));
expect(offenders).toEqual([]);
});
});
+59 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { IncomingMessage } from "http";
import { extractWebSocketToken } from "../../utils/ws-auth.js";
import {
extractWebSocketToken,
isCookieOriginAllowed,
} from "../../utils/ws-auth.js";
function request(headers: Record<string, string>): IncomingMessage {
return { headers } as IncomingMessage;
@@ -26,3 +29,58 @@ describe("extractWebSocketToken", () => {
).toBe("cookie-token");
});
});
describe("the jwt cookie on a socket", () => {
it("counts when the page is this server", () => {
expect(
extractWebSocketToken(
request({
cookie: "jwt=cookie-token",
host: "termix.example.com",
origin: "https://termix.example.com",
}),
),
).toBe("cookie-token");
});
it("is ignored when another site opened the socket", () => {
expect(
extractWebSocketToken(
request({
cookie: "jwt=cookie-token",
host: "termix.example.com",
origin: "https://evil.example.com",
}),
),
).toBeUndefined();
});
it("still takes an explicit token from another origin", () => {
expect(
extractWebSocketToken(
request({
cookie: "jwt=cookie-token",
host: "termix.example.com",
origin: "https://evil.example.com",
"sec-websocket-protocol": "termix.jwt.protocol-token",
}),
),
).toBe("protocol-token");
});
it("allows the dev proxy, which rewrites Host to loopback", () => {
expect(
isCookieOriginAllowed(
request({ host: "127.0.0.1:30001", origin: "http://localhost:5173" }),
),
).toBe(true);
});
it("allows the desktop app, which is not a web origin", () => {
expect(
isCookieOriginAllowed(
request({ host: "termix.example.com", origin: "file://" }),
),
).toBe(true);
});
});
+2
View File
@@ -34,6 +34,7 @@ import { runSecretSourcesTokenMigration } from "./secret-sources-token-migration
import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
@@ -64,6 +65,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runTotpMigration", runTotpMigration],
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
@@ -0,0 +1,59 @@
/**
* Moves the silent sign-in default out of core settings and into the sso
* plugin's admin settings. Core used to own the toggle and read it on the
* login page; the sso plugin now marks its own login instance to start on
* its own.
*
* Idempotent: a value already in plugin settings wins and the core copy is
* dropped.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "sso";
const LEGACY_KEY = "oidc_silent_login_default";
const FIELD = "silentLoginDefault";
export interface SsoSettingsMigrationResult {
moved: boolean;
}
export async function runSsoSettingsMigration(): Promise<SsoSettingsMigrationResult> {
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return { moved: false };
const settings = createCurrentSettingsRepository();
const legacy = await settings.get(LEGACY_KEY);
if (legacy === null || legacy === "") return { moved: false };
const pluginSettings = createCurrentPluginSettingsRepository();
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, FIELD);
if (!existing || existing.value === null) {
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
FIELD,
JSON.stringify(legacy === "true"),
false,
);
}
await settings.delete(LEGACY_KEY);
databaseLogger.info("Moved the silent sign-in default into sso settings", {
operation: "sso_settings_migration",
});
return { moved: true };
} catch (error) {
databaseLogger.warn("SSO settings migration failed", {
operation: "sso_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
return { moved: false };
}
}
+3 -4
View File
@@ -137,7 +137,7 @@ class AuthManager {
}
}
async registerOIDCUser(
async registerExternalUser(
userId: string,
_sessionDurationMs?: number,
): Promise<void> {
@@ -154,7 +154,7 @@ class AuthManager {
return false;
}
async authenticateOIDCUser(
async authenticateExternalUser(
userId: string,
_deviceType?: DeviceType,
): Promise<boolean> {
@@ -505,7 +505,7 @@ class AuthManager {
}
}
async revokeSessionsByOidc(params: {
async revokeSessionsByExternalSession(params: {
ssoProviderId?: number | null;
sub?: string | null;
sid?: string | null;
@@ -529,7 +529,6 @@ class AuthManager {
if (matched.length === 0) return 0;
const matchedIds = matched.map((s) => s.id);
const affectedUsers = new Set(matched.map((s) => s.userId));
await db.delete(sessions).where(inArray(sessions.id, matchedIds));
@@ -5,7 +5,7 @@ const CALLBACK_PATH = "/oidc-callback";
* listener or the mobile app's scheme, which get the token in the URL
* because they cannot read a cookie.
*/
export function isOidcTokenCallback(value: string): boolean {
export function isExternalTokenCallback(value: string): boolean {
if (value.startsWith("termix-mobile:")) return true;
try {
-7
View File
@@ -34,7 +34,6 @@ export interface LogContext {
operation?: string;
userId?: string;
hostId?: number;
tunnelName?: string;
sessionId?: string;
requestId?: string;
duration?: number;
@@ -139,8 +138,6 @@ export class Logger {
contextParts.push(`user:${sanitizedContext.userId}`);
if (sanitizedContext.hostId)
contextParts.push(`host:${sanitizedContext.hostId}`);
if (sanitizedContext.tunnelName)
contextParts.push(`tunnel:${sanitizedContext.tunnelName}`);
if (sanitizedContext.sessionId)
contextParts.push(`session:${sanitizedContext.sessionId}`);
if (sanitizedContext.requestId)
@@ -255,10 +252,6 @@ export class Logger {
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: "file" });
}
-84
View File
@@ -1,84 +0,0 @@
import http from "http";
import type { RequestListener } from "http";
import type { Logger } from "./logger.js";
interface ServiceListenOptions {
app: RequestListener;
port: number;
logger: Logger;
/** Used only to identify the service in the conflict log. */
serviceName: string;
host?: string;
onListening?: () => void;
}
/**
* Starts one of the backend's fixed-port services.
*
* Express's `app.listen(port, host, callback)` registers that callback as
* the server's `error` handler as well as its `listening` handler:
*
* if (typeof args[args.length - 1] === 'function') {
* var done = args[args.length - 1] = once(args[args.length - 1])
* server.once('error', done)
* }
*
* so a port conflict called the service's "started" callback with an
* EADDRINUSE error as its argument and emitted nothing. Since none of the
* services read that argument, a failed bind was indistinguishable from a
* successful one: the service logged that it had started, ran its
* initialisation, and served nothing, with no error anywhere in the logs.
*
* Building the server here keeps `listening` and `error` separate. A port
* conflict is treated the way database.ts already treats one on the main
* HTTP port -- reported with the port that was taken, then exit -- rather
* than leaving the backend up with one feature silently missing. Exiting
* also gives the desktop app the classified failure it already knows how
* to surface, so the user is told which port to free.
*/
/**
* Reports a port conflict on an already-built service server and stops the
* backend, for services that must own their `http.Server` (the tunnel
* service attaches a WebSocket upgrade handler to it, for example) and so
* cannot go through {@link listenOnServicePort}.
*/
function attachServicePortConflictHandler(
server: http.Server,
port: number,
logger: Logger,
serviceName: string,
): void {
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
logger.error(
`Port ${port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "service_port_conflict",
port,
service: serviceName,
},
);
// process.exit() does not return, so nothing below runs in
// production; the explicit return keeps that obvious and keeps the
// rethrow below reachable only for genuinely unexpected errors.
process.exit(1);
return;
}
throw err;
});
}
export function listenOnServicePort({
app,
port,
logger,
serviceName,
host = "127.0.0.1",
onListening,
}: ServiceListenOptions): http.Server {
const server = http.createServer(app);
attachServicePortConflictHandler(server, port, logger, serviceName);
server.listen(port, host, onListening);
return server;
}
+43 -88
View File
@@ -8,6 +8,42 @@ type SwaggerJSDocOptions = Parameters<typeof swaggerJSDoc>[0];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// dist/backend/backend/utils -> the repository.
const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..");
/**
* A tag per plugin, named and described by its manifest, so the reference
* groups a plugin's routes without core naming any plugin.
*/
async function pluginTags(): Promise<{ name: string; description: string }[]> {
const pluginsDir = path.join(REPO_ROOT, "plugins");
const tags: { name: string; description: string }[] = [];
let entries: string[] = [];
try {
entries = await fs.readdir(pluginsDir);
} catch {
return tags;
}
for (const entry of entries.sort()) {
try {
const manifest = JSON.parse(
await fs.readFile(
path.join(pluginsDir, entry, "manifest.json"),
"utf8",
),
) as { name?: string; description?: string };
if (manifest.name) {
tags.push({
name: manifest.name,
description: manifest.description ?? "",
});
}
} catch {
// Not a plugin folder.
}
}
return tags;
}
const swaggerOptions: SwaggerJSDocOptions = {
definition: {
@@ -47,30 +83,14 @@ const swaggerOptions: SwaggerJSDocOptions = {
},
],
tags: [
{
name: "AI",
description: "AI assistant providers, conversations and proposals",
},
{
name: "Alerts",
description: "System alerts and notifications management",
},
{
name: "Credentials",
description: "SSH credential management",
},
{
name: "Network Topology",
description: "Network topology visualization and management",
},
{
name: "RBAC",
description: "Role-based access control for host sharing",
},
{
name: "Terminal",
description: "Terminal command history",
},
{
name: "Users",
description: "User management and authentication",
@@ -79,22 +99,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "Dashboard",
description: "Dashboard statistics and activity",
},
{
name: "Docker",
description: "Docker container management",
},
{
name: "SSH Tunnels",
description: "SSH tunnel connection management",
},
{
name: "Host Metrics",
description: "Host status monitoring, metrics collection, and managers",
},
{
name: "File Manager",
description: "SSH file management operations",
},
{
name: "SSH",
description: "SSH host management and configuration",
@@ -103,50 +107,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "Host Enrollment",
description: "Host enrollment and onboarding",
},
{
name: "Fleets",
description: "Fleet grouping, membership, and inventory",
},
{
name: "Workspaces",
description: "Saved tab and split layouts",
},
{
name: "Open Tabs",
description: "Per-user open tab state",
},
{
name: "Automations",
description: "Scheduled and triggered automations",
},
{
name: "Guacamole",
description: "Remote desktop sessions",
},
{
name: "Proxmox",
description: "Proxmox host integration",
},
{
name: "Proxmox Stats",
description: "Proxmox node and VM statistics",
},
{
name: "Session Sharing",
description: "Share live sessions by link or with another user",
},
{
name: "Collab",
description: "Collaboration rooms that present a live session",
},
{
name: "Session Logs",
description: "Session recording and playback",
},
{
name: "Homepage",
description: "Homepage service links and layout",
},
{
name: "Audit",
description: "Audit log querying and export",
@@ -155,26 +119,10 @@ const swaggerOptions: SwaggerJSDocOptions = {
name: "API Keys",
description: "API key management",
},
{
name: "Vault",
description: "HashiCorp Vault SSH signing profiles",
},
{
name: "Termix ID",
description: "Built-in SSH certificate authority",
},
{
name: "Tailscale",
description: "Tailscale network integration",
},
{
name: "Sync",
description: "Remote sync between desktop and server",
},
{
name: "Tunnel Presets",
description: "Saved tunnel configurations",
},
{
name: "User Preferences",
description: "Per-user application preferences",
@@ -201,6 +149,11 @@ const swaggerOptions: SwaggerJSDocOptions = {
path.join(__dirname, "..", "services", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "*.js").replace(/\\/g, "/"),
path.join(__dirname, "..", "hosts", "**", "*.js").replace(/\\/g, "/"),
// Plugin routes document themselves. Read from source, because a plugin's
// bundle drops comments.
path
.join(REPO_ROOT, "plugins", "*", "src", "backend", "**", "*.ts")
.replace(/\\/g, "/"),
],
};
@@ -210,6 +163,8 @@ async function generateOpenAPISpec() {
operation: "openapi_generate_start",
});
const definition = swaggerOptions.definition as { tags?: unknown[] };
definition.tags = [...(definition.tags ?? []), ...(await pluginTags())];
const swaggerSpec = await swaggerJSDoc(swaggerOptions);
const outputPath = path.join(
+40 -1
View File
@@ -2,11 +2,50 @@ import type { IncomingMessage } from "http";
const JWT_PROTOCOL_PREFIX = "termix.jwt.";
/**
* A browser attaches the jwt cookie to a socket opened from any page on the
* same site, including a sibling subdomain. The cookie only counts when the
* page that opened the socket is this server. A non-web origin (the desktop
* app) or no Origin at all is not a browser page on another host.
*/
export function isCookieOriginAllowed(req: IncomingMessage): boolean {
const origin = req.headers.origin;
if (!origin) return true;
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true;
const hosts = [req.headers.host, req.headers["x-forwarded-host"]]
.flatMap((value) => String(value ?? "").split(","))
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (hosts.includes(parsed.host.toLowerCase())) return true;
// The Vite dev proxy rewrites Host, so loopback to loopback is allowed.
if (
isLoopback(parsed.hostname) &&
hosts.some((host) => isLoopback(host.replace(/:\d+$/, "")))
) {
return true;
}
return (process.env.TERMIX_ALLOWED_ORIGINS ?? "")
.split(",")
.map((value) => value.trim().toLowerCase())
.includes(parsed.origin.toLowerCase());
}
function isLoopback(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
return host === "localhost" || host === "::1" || host.startsWith("127.");
}
export function extractWebSocketToken(
req: IncomingMessage,
): string | undefined {
const cookieHeader = req.headers.cookie;
if (cookieHeader) {
if (cookieHeader && isCookieOriginAllowed(req)) {
const match = cookieHeader.match(/(?:^|;\s*)jwt=([^;]+)/);
if (match) return decodeURIComponent(match[1]);
}