mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -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",
|
||||
}),
|
||||
|
||||
+4
-4
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user