Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion server/src/config/auth-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, sep } from "node:path";
import {
assertValidAuthPassword,
authTokenPrintCommand,
isShortAuthPassword,
defaultAuthTokenPath,
loadOrCreateAuthToken,
Expand Down Expand Up @@ -78,6 +79,143 @@ describe("generated authentication token", () => {
);
});

test("prints the token without revealing the account name", () => {
const home = join("C:", "Users", "tester");
const appData = join(home, "AppData", "Roaming");
expect(
authTokenPrintCommand(
join(appData, "roamgate", "auth-token"),
home,
"win32",
appData,
),
).toBe('Get-Content "$env:APPDATA\\roamgate\\auth-token"');
expect(
authTokenPrintCommand(
join(home, ".config", "roamgate", "auth-token"),
home,
"win32",
undefined,
),
).toBe('Get-Content "$HOME\\.config\\roamgate\\auth-token"');
expect(
authTokenPrintCommand(
"/home/tester/.config/roamgate/auth-token",
"/home/tester",
"linux",
),
).toBe("cat ~/.config/roamgate/auth-token");
expect(
authTokenPrintCommand(
"/Users/tester/.config/roamgate/auth-token",
"/Users/tester",
"darwin",
),
).toBe("cat ~/.config/roamgate/auth-token");
});

test("prints the default token command with trailing HOME separators", () => {
const home = tempHome();
for (const suffix of [sep, sep.repeat(2)]) {
const homeWithSeparator = home + suffix;
const path = defaultAuthTokenPath(
homeWithSeparator,
process.platform,
join(home, "AppData", "Roaming"),
);
expect(
authTokenPrintCommand(path, homeWithSeparator, process.platform, ""),
).toBe(
process.platform === "win32"
? 'Get-Content "$HOME\\AppData\\Roaming\\roamgate\\auth-token"'
: "cat ~/.config/roamgate/auth-token",
);
}
});

test("handles filesystem-root HOME without losing the boundary", () => {
expect(
authTokenPrintCommand("/.config/roamgate/auth-token", "/", "linux"),
).toBe("cat ~/.config/roamgate/auth-token");
expect(
authTokenPrintCommand("relative/auth-token", "/", "linux"),
).toBeUndefined();
});

// These are command-string tests, not execution in a Windows shell.
test("prints Windows commands with trailing APPDATA or HOME separators", () => {
const home = "C:\\Users\\tester";
const appData = `${home}\\AppData\\Roaming`;
for (const suffix of ["\\", "\\\\", "/"]) {
expect(
authTokenPrintCommand(
`${appData}\\roamgate\\auth-token`,
home,
"win32",
appData + suffix,
),
).toBe('Get-Content "$env:APPDATA\\roamgate\\auth-token"');
expect(
authTokenPrintCommand(
`${home}\\.config\\roamgate\\auth-token`,
home + suffix,
"win32",
"",
),
).toBe('Get-Content "$HOME\\.config\\roamgate\\auth-token"');
expect(
authTokenPrintCommand(
`${appData}Extra\\auth-token`,
"C:\\OtherUser",
"win32",
appData + suffix,
),
).toBeUndefined();
}
});

test("keeps adjacent prefixes and unsafe shell paths omitted", () => {
expect(
authTokenPrintCommand(
"/home/tester-other/auth-token",
"/home/tester/",
"linux",
),
).toBeUndefined();
for (const name of ["token name", "token;echo", "token$HOME"]) {
expect(
authTokenPrintCommand(`/home/tester/${name}`, "/home/tester/", "linux"),
).toBeUndefined();
}
for (const name of ['token"', "token`", "token$HOME"]) {
expect(
authTokenPrintCommand(
`C:\\Users\\tester\\${name}`,
"C:\\Users\\tester\\",
"win32",
"",
),
).toBeUndefined();
}
});

test("omits the token command outside the home directory", () => {
expect(
authTokenPrintCommand(
"/srv/roamgate/auth-token",
"/home/tester",
"linux",
),
).toBeUndefined();
expect(
authTokenPrintCommand(
"/home/testerx/auth-token",
"/home/tester",
"linux",
),
).toBeUndefined();
});

test("creates a persistent random token with private permissions", () => {
const path = tempAuthTokenPath();
const first = loadOrCreateAuthToken(path);
Expand Down
33 changes: 33 additions & 0 deletions server/src/config/auth-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,39 @@ export function defaultAuthTokenPath(
return defaultDataFile("auth-token", homeDir, platform, appDataDir);
}

function pathUnder(path: string, root: string | undefined): string | null {
if (!root) return null;
// Keep the separator boundary check even when the root already ends in one.
const prefix = root.replace(/[\\/]+$/, "");
if (!path.startsWith(prefix)) return null;
const rest = path.slice(prefix.length);
return /^[\\/]/.test(rest) ? rest.slice(1) : null;
}

/**
* A host shell command that prints the generated token, for the login page.
* It names the file relative to the home or APPDATA directory so the
* unauthenticated page never reveals the account name.
*/
export function authTokenPrintCommand(
path: string,
homeDir = homedir(),
platform = process.platform,
appDataDir = process.env.APPDATA,
): string | undefined {
if (platform === "win32") {
const inAppData = pathUnder(path, appDataDir);
const inHome = inAppData === null ? pathUnder(path, homeDir) : null;
const rest = inAppData ?? inHome;
if (rest === null || /["`$]/.test(rest)) return undefined;
const root = inAppData === null ? "$HOME" : "$env:APPDATA";
return `Get-Content "${root}\\${rest.replaceAll("/", "\\")}"`;
}
const rest = pathUnder(path, homeDir);
if (rest === null || !/^[\w./-]+$/.test(rest)) return undefined;
return `cat ~/${rest}`;
}

export function readAuthToken(path: string): string {
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
try {
Expand Down
4 changes: 3 additions & 1 deletion server/src/http/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export function createAuthHandlers(args: {
sessionSecret: string;
pin?: string;
urlLoginToken?: string;
/** Host command that prints the generated token, shown on the login page. */
tokenCommand?: string;
secureCookies?: boolean;
}) {
const authRequired = args.authRequired ?? true;
Expand Down Expand Up @@ -366,7 +368,7 @@ export function createAuthHandlers(args: {
headers: { location: "/", "cache-control": "no-store" },
});
}
return new Response(renderLoginPage(Boolean(args.pin)), {
return new Response(renderLoginPage(Boolean(args.pin), args.tokenCommand), {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
Expand Down
89 changes: 87 additions & 2 deletions server/src/http/login-page-pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { createContext, runInContext } from "node:vm";
import { type HTMLButtonElement, Window } from "happy-dom";
import { LOGIN_HTML, renderLoginPage } from "./login-page";

function loginPage(pinEnabled = true) {
function loginPage(
pinEnabled = true,
tokenCommand?: string,
globals: Record<string, unknown> = {},
) {
const window = new Window({
settings: {
disableJavaScriptEvaluation: true,
Expand All @@ -12,7 +16,7 @@ function loginPage(pinEnabled = true) {
},
});
const document = window.document;
document.write(renderLoginPage(pinEnabled));
document.write(renderLoginPage(pinEnabled, tokenCommand));
const calls: { url: string; init: RequestInit }[] = [];
const responses: (() => Promise<Response>)[] = [];
const location = {
Expand All @@ -23,6 +27,7 @@ function loginPage(pinEnabled = true) {
},
};
const context = createContext({
...globals,
document,
location,
fetch: (url: string, init: RequestInit) => {
Expand Down Expand Up @@ -363,3 +368,83 @@ describe("optional PIN login page", () => {
expect(page.location.replacements).toHaveLength(1);
});
});

describe("login page token help", () => {
test("only token logins explain where to find the token", () => {
const command = 'Get-Content "$env:APPDATA\\roamgate\\auth-token"';
expect(LOGIN_HTML).not.toContain("token-cmd");
for (const pinEnabled of [false, true]) {
const page = loginPage(pinEnabled, command);
expect(page.document.querySelector("#token-cmd")?.textContent).toBe(
command,
);
const help = page.document.querySelector("details")!;
expect(help.open).toBe(false);
expect(help.querySelector("a")?.getAttribute("href")).toEndWith(
"docs/DEPLOYMENT.md#basic-runtime-configuration",
);
expect(help.hidden).toBe(pinEnabled);
if (pinEnabled) {
page.forceSwitch();
expect(help.hidden).toBe(false);
page.forceSwitch();
expect(help.hidden).toBe(true);
}
}
});

test.each([false, true])(
"phone help distinguishes the user service from foreground instances (PIN: %s)",
(pinEnabled) => {
const page = loginPage(pinEnabled, "cat ~/.config/roamgate/auth-token");
if (pinEnabled) page.forceSwitch();
const help = page.document.querySelector("#help")!;
expect(help.hasAttribute("hidden")).toBe(false);
const phoneHelp = [...help.querySelectorAll("p")].find((p) =>
p.textContent.includes("On a phone?"),
)!;
expect(phoneHelp.querySelector("code")?.textContent).toBe(
"roamgate url --qr",
);
expect(phoneHelp.textContent).toContain(
"For an installed Roamgate user service",
);
expect(phoneHelp.textContent).toContain("uses the service configuration");
expect(phoneHelp.textContent).toContain(
"For a foreground instance, keep using this instance's address",
);
expect(phoneHelp.textContent).toContain(
"read its token on the host with the command above",
);
},
);

test("escapes the command", () => {
const html = renderLoginPage(false, 'cat ~/<b>&"x"');
expect(html).toContain("cat ~/&#60;b&#62;&#38;&#34;x&#34;");
expect(html).not.toContain("<b>");
});

test("copies the command only in a secure context", async () => {
expect(
loginPage(
false,
"cat ~/.config/roamgate/auth-token",
).document.querySelector<HTMLButtonElement>("#copy")?.hidden,
).toBe(true);
const copied: string[] = [];
const page = loginPage(false, "cat ~/.config/roamgate/auth-token", {
isSecureContext: true,
navigator: {
clipboard: { writeText: async (text: string) => copied.push(text) },
},
setTimeout,
clearTimeout,
});
const copy = page.document.querySelector<HTMLButtonElement>("#copy")!;
expect(copy.hidden).toBe(false);
await (copy.onclick as unknown as () => Promise<void>)();
expect(copied).toEqual(["cat ~/.config/roamgate/auth-token"]);
expect(copy.textContent).toBe("Copied");
});
});
Loading
Loading