Files
doktainer-upstream/tests/security-hardening.test.ts
KodekaTeam 87fdbb493b feat(sessions): implement user session management with session tracking and revocation
- Introduced new database schema for user sessions and session events.
- Implemented session management page with filtering, sorting, and revocation capabilities.
- Added backend services for creating, revoking, and expiring user sessions.
- Implemented session cookie handling and security measures.
- Created tests for session management functionalities and security features.
2026-09-11 13:34:01 +07:00

276 lines
7.2 KiB
TypeScript

import assert from "node:assert/strict";
import test, { afterEach, beforeEach } from "node:test";
import {
clearLegacyAuthStorage,
sensitiveStorageKeys,
setSensitiveStorageItem,
} from "../src/lib/browser-storage.ts";
import {
auth,
getUser,
setUser,
} from "../src/lib/api.ts";
import {
consumeGithubManifestState,
encodeGithubManifestState,
storeGithubManifestState,
type GithubManifestState,
} from "../src/lib/github-manifest-state.ts";
import {
buildContentSecurityPolicy,
securityHeaders,
} from "../src/lib/security-headers.ts";
import {
buildTerminalWebSocketUrl,
resolveWebSocketBaseUrl,
} from "../src/lib/websocket-security.ts";
import {
sanitizeLogText,
sanitizeTerminalStreamChunk,
} from "../src/lib/terminal-output.ts";
class MemoryStorage implements Storage {
private store = new Map<string, string>();
get length(): number {
return this.store.size;
}
clear(): void {
this.store.clear();
}
getItem(key: string): string | null {
return this.store.has(key) ? this.store.get(key)! : null;
}
key(index: number): string | null {
return [...this.store.keys()][index] ?? null;
}
removeItem(key: string): void {
this.store.delete(key);
}
setItem(key: string, value: string): void {
this.store.set(key, value);
}
}
type TestWindow = Window & {
localStorage: Storage;
sessionStorage: Storage;
location: Location & {
replace: (url: string) => void;
};
addEventListener: (
type: string,
listener: EventListenerOrEventListenerObject,
) => void;
removeEventListener: (
type: string,
listener: EventListenerOrEventListenerObject,
) => void;
dispatchEvent: (event: Event) => boolean;
};
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
function createWindow(): TestWindow {
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
let pathname = "/";
let search = "";
return {
localStorage: new MemoryStorage(),
sessionStorage: new MemoryStorage(),
location: {
get pathname() {
return pathname;
},
get search() {
return search;
},
replace(url: string) {
const target = new URL(url, "http://localhost");
pathname = target.pathname;
search = target.search;
},
} as TestWindow["location"],
addEventListener(type, listener) {
if (!listeners.has(type)) {
listeners.set(type, new Set());
}
listeners.get(type)!.add(listener);
},
removeEventListener(type, listener) {
listeners.get(type)?.delete(listener);
},
dispatchEvent(event) {
const registered = listeners.get(event.type);
if (!registered) {
return true;
}
for (const listener of registered) {
if (typeof listener === "function") {
listener(event);
continue;
}
listener.handleEvent(event);
}
return true;
},
} as TestWindow;
}
beforeEach(() => {
globalThis.window = createWindow();
});
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalWindow === undefined) {
delete globalThis.window;
return;
}
globalThis.window = originalWindow;
});
test("removes legacy auth credentials without deleting organization preference", () => {
window.localStorage.setItem("doktainer_token", "legacy-token");
window.localStorage.setItem("doktainer_user", '{"id":"legacy"}');
setSensitiveStorageItem(sensitiveStorageKeys.organization, "org-1");
clearLegacyAuthStorage();
assert.equal(window.localStorage.getItem("doktainer_token"), null);
assert.equal(window.localStorage.getItem("doktainer_user"), null);
assert.equal(
window.localStorage.getItem(sensitiveStorageKeys.organization),
"org-1",
);
});
test("clears stored session and redirects to login on authenticated 401", async () => {
setUser({
id: "user-1",
name: "User",
email: "user@example.com",
role: "OPERATOR",
});
let requestInit: RequestInit | undefined;
globalThis.fetch = async (_input, init) => {
requestInit = init;
return new Response(
JSON.stringify({
success: false,
error: "Unauthorized - session expired",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
},
);
};
await assert.rejects(() => auth.logout(), /Unauthorized - session expired/);
assert.equal(getUser(), null);
assert.equal(requestInit?.credentials, "include");
assert.equal(
(requestInit?.headers as Record<string, string>)["x-doktainer-request"],
"1",
);
assert.equal(window.location.pathname, "/login");
assert.equal(window.location.search, "?reason=session-expired");
});
test("stores and consumes GitHub manifest state as single-use session data", () => {
const payload: GithubManifestState = {
appName: "Doktainer GitHub App",
configName: "primary",
organizationScoped: true,
organizationName: "acme",
nonce: "nonce-123",
};
const rawState = encodeGithubManifestState(payload);
storeGithubManifestState(rawState);
assert.deepEqual(consumeGithubManifestState(rawState), payload);
assert.equal(consumeGithubManifestState(rawState), null);
});
test("rejects mismatched GitHub manifest state", () => {
const payload: GithubManifestState = {
appName: "Doktainer GitHub App",
configName: "primary",
organizationScoped: false,
organizationName: "",
nonce: "nonce-123",
};
storeGithubManifestState(encodeGithubManifestState(payload));
assert.equal(consumeGithubManifestState("tampered-state"), null);
});
test("builds baseline security headers with anti-clickjacking directives", () => {
const headerMap = new Map(
securityHeaders.map((header) => [header.key, header.value]),
);
const csp = buildContentSecurityPolicy();
assert.equal(headerMap.get("X-Frame-Options"), "DENY");
assert.equal(headerMap.get("X-Content-Type-Options"), "nosniff");
assert.match(csp, /frame-ancestors 'none'/);
assert.match(csp, /object-src 'none'/);
assert.match(csp, /connect-src 'self' http: https: ws: wss:/);
});
test("upgrades websocket URLs on secure origins and keeps terminal params encoded", () => {
assert.equal(
resolveWebSocketBaseUrl(undefined, "https://panel.example.com"),
"wss://panel.example.com",
);
const target = new URL(
buildTerminalWebSocketUrl({
configuredUrl: "ws://panel.example.com/socket",
browserOrigin: "https://panel.example.com",
serverId: "server/id",
cols: 120,
rows: 40,
sessionId: "session id",
ticket: "ticket/with?chars",
}),
);
assert.equal(target.protocol, "wss:");
assert.equal(target.pathname, "/api/v1/terminal/ws/server%2Fid");
assert.equal(target.searchParams.get("sessionId"), "session id");
assert.equal(target.searchParams.get("ticket"), "ticket/with?chars");
});
test("sanitizes dangerous terminal and log control sequences", () => {
const terminalChunk =
"safe\u001b]0;window-title\u0007\u001b[31mred\u001b[0m\u0000text\b \b";
const logChunk =
"log\u001b]8;;https://attacker.example\u0007click\u001b]8;;\u0007\u001b[31mred\u001b[0m\u0007";
assert.equal(
sanitizeTerminalStreamChunk(terminalChunk),
"safe\u001b[31mred\u001b[0mtext\b \b",
);
assert.equal(sanitizeLogText(logChunk), "logclickred");
});