Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG-Nns-Dapp-unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ proposal is successful, the changes it released will be moved from this file to

#### Security

- The sign-out message in the URL is now limited to known messages. Before, a
crafted link could show any text as an official toast.

#### Not Published

### Operations
Expand Down
44 changes: 25 additions & 19 deletions frontend/src/lib/services/auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { browser } from "$app/environment";
import { authStore } from "$lib/stores/auth.store";
import { startBusy } from "$lib/stores/busy.store";
import { toastsError, toastsShow } from "$lib/stores/toasts.store";
import type { ToastMsg } from "$lib/types/toast";
import type { I18nKeys } from "$lib/utils/i18n.utils";
import { replaceHistory } from "$lib/utils/route.utils";
import { registerCleanupForTesting } from "$lib/utils/test-support.utils";
import type { ToastLevel } from "@dfinity/gix-components";
Expand All @@ -13,6 +13,18 @@ import { get } from "svelte/store";
const msgParam = "msg";
const levelParam = "level";

// The only messages that a sign-out can carry in the url. The app owns the
// level, so the url cannot pick the styling of the toast.
const LOGOUT_MSGS = {
"error.missing_identity": "error",
"warning.auth_sign_out": "warn",
} as const satisfies Partial<Record<I18nKeys, ToastLevel>>;

export type LogoutMsgKey = keyof typeof LOGOUT_MSGS;

const isLogoutMsgKey = (msg: string): msg is LogoutMsgKey =>
Object.hasOwn(LOGOUT_MSGS, msg);

let logoutInProgress = false;

registerCleanupForTesting(() => {
Expand All @@ -35,11 +47,7 @@ export const login = async () => {
await authStore.signIn(onError);
};

export const logout = async ({
msg = undefined,
}: {
msg?: Pick<ToastMsg, "labelKey" | "level">;
}) => {
export const logout = async ({ msg = undefined }: { msg?: LogoutMsgKey }) => {
// Prevent re-entrant logout calls. When authStore.signOut() sets identity
// to null, reactive cascades can cause multiple services to detect the
// missing identity and each independently call logout() again, appending
Expand Down Expand Up @@ -89,7 +97,7 @@ export const getAuthenticatedIdentity = async (): Promise<Identity> => {

if (!identity) {
await logout({
msg: { labelKey: "error.missing_identity", level: "error" },
msg: "error.missing_identity",
});

// We do not resolve on purpose. logout() does reload the browser
Expand All @@ -101,17 +109,17 @@ export const getAuthenticatedIdentity = async (): Promise<Identity> => {
};

/**
* If a message was provided to the logout process - e.g. a message informing the logout happened because the session timed-out - append the information to the url as query params
* If a message was provided to the logout process - e.g. a message informing the logout happened because the session timed-out - append the key to the url as a query param
*/
const appendMsgToUrl = (msg: Pick<ToastMsg, "labelKey" | "level">) => {
const { labelKey, level } = msg;

const appendMsgToUrl = (msg: LogoutMsgKey) => {
if (!browser) return;

const url: URL = new URL(window.location.href);

url.searchParams.set(msgParam, encodeURI(labelKey));
url.searchParams.set(levelParam, level);
// Drop a pre-existing level param, so the url never carries an untrusted
// level value, even one left over from a crafted or legacy link.
url.searchParams.delete(levelParam);
url.searchParams.set(msgParam, msg);

replaceHistory(url);
Comment thread
yhabib marked this conversation as resolved.
};
Expand All @@ -128,15 +136,13 @@ export const displayAndCleanLogoutMsg = () => {

const msg: string | null = urlParams.get(msgParam);

if (msg === null) {
if (msg === null && !urlParams.has(levelParam)) {
return;
}

// For simplicity reason we assume the level pass as query params is one of the type ToastLevel
const level: ToastLevel =
(urlParams.get(levelParam) as ToastLevel | null) ?? "success";

toastsShow({ labelKey: msg, level });
if (msg !== null && isLogoutMsgKey(msg)) {
toastsShow({ labelKey: msg, level: LOGOUT_MSGS[msg] });
}

cleanUpMsgUrl();
};
Expand Down
5 changes: 1 addition & 4 deletions frontend/src/lib/services/worker-auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ export const initAuthWorker = async (): Promise<AuthWorker> => {
switch (msg) {
case "nnsSignOut":
await logout({
msg: {
labelKey: "warning.auth_sign_out",
level: "warn",
},
msg: "warning.auth_sign_out",
});
return;
case "nnsDelegationRemainingTime":
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/tests/e2e/logout-msg.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { AppPo } from "$tests/page-objects/App.page-object";
import { PlaywrightPageObjectElement } from "$tests/page-objects/playwright.page-object";
import { signInWithNewUser, step } from "$tests/utils/e2e.test-utils";
import { expect, test, type Page } from "@playwright/test";

// The global expect timeout is 0, which means "wait forever", so every poll
// below sets its own timeout.
const POLL_TIMEOUT = 30_000;

// Playwright cannot import en.json here, so the texts are copied.
// "error.missing_identity" in frontend/src/lib/i18n/en.json.
const missingIdentityText =
"The operation cannot be executed without any identity.";
// "warning.auth_sign_out" in frontend/src/lib/i18n/en.json.
const authSignOutText =
"You have been logged out because your session has expired.";

const craftedMsg = "Send your ICP to this address to recover your account";

// initAppAuth deletes both parameters on every page load.
const waitForCleanUrl = async (page: Page) => {
await expect
.poll(() => new URL(page.url()).searchParams.get("msg"), {
timeout: POLL_TIMEOUT,
})
.toBeNull();
expect(new URL(page.url()).searchParams.get("level")).toBeNull();
};
Comment thread
yhabib marked this conversation as resolved.

const getToastMessages = (appPo: AppPo): Promise<string[]> =>
appPo.getToastsPo().getMessages();

const getToastClasses = (appPo: AppPo): Promise<string[] | null> =>
appPo.getToastsPo().getToastPo().root.getClasses();

test("Test the msg url parameter", async ({ page }) => {
const appPo = new AppPo(PlaywrightPageObjectElement.fromPage(page));

await step("A msg that is not in the allowlist shows no toast");
await page.goto(
`/accounts?msg=${encodeURIComponent(craftedMsg)}&level=error`
);
await appPo.getSignInPo().waitFor();
await waitForCleanUrl(page);
expect(await getToastMessages(appPo)).toEqual([]);

await step("A msg in the allowlist shows its own text and its own level");
await page.goto("/accounts?msg=error.missing_identity&level=success");
await appPo.getSignInPo().waitFor();
await expect
.poll(() => getToastMessages(appPo), { timeout: POLL_TIMEOUT })
.toEqual([missingIdentityText]);
// The url asked for "success". The app owns the level, so the toast is an
// error.
expect(await getToastClasses(appPo)).toContain("error");
expect(await getToastClasses(appPo)).not.toContain("success");
await waitForCleanUrl(page);
});

test("Test the toast after an automatic sign out", async ({
page: page1,
context,
}) => {
await page1.goto("/accounts");
await expect(page1).toHaveTitle("Account | Network Nervous System");
const appPo1 = new AppPo(PlaywrightPageObjectElement.fromPage(page1));

const page2 = await context.newPage();
await page2.goto("/accounts");
await expect(page2).toHaveTitle("Account | Network Nervous System");
const appPo2 = new AppPo(PlaywrightPageObjectElement.fromPage(page2));

await signInWithNewUser({ page: page1, context });
await appPo1.getAccountsPo().waitFor();

await page2.reload();
await appPo2.getAccountsPo().waitFor();

await step("Sign out in the first tab");
await appPo1.getAccountMenuPo().openMenu();
await appPo1.getAccountMenuPo().clickLogout();
await appPo1.getSignInPo().waitFor();

await step("The second tab shows the session expiry toast");
// The auth worker of the second tab sees the missing delegation, calls
// logout with "warning.auth_sign_out" and reloads the page.
await appPo2.getSignInPo().waitFor();
await expect
.poll(() => getToastMessages(appPo2), { timeout: POLL_TIMEOUT })
.toContain(authSignOutText);
expect(await getToastClasses(appPo2)).toContain("warn");
await waitForCleanUrl(page2);
});
Loading
Loading