-
-
Notifications
You must be signed in to change notification settings - Fork 9
fix(security): migrate MCP pairing token from storage.local to storage.session #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,54 +1,92 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { validatePairingParams } from "../panel/components/MCPPairingPanel"; | ||
|
|
||
| const VALID_TOKEN = "a".repeat(64); | ||
| const VALID_HASH = `#token=${VALID_TOKEN}&port=65000`; | ||
|
|
||
| const mockSessionStorage: Record<string, unknown> = {}; | ||
| const mockLocalStorage: Record<string, unknown> = {}; | ||
|
|
||
| vi.stubGlobal("chrome", { | ||
| storage: { | ||
| session: { | ||
| set: vi.fn(async (data: Record<string, unknown>) => { | ||
| Object.assign(mockSessionStorage, data); | ||
| }), | ||
| get: vi.fn(async (keys: string[]) => { | ||
| return Object.fromEntries(keys.map((k) => [k, mockSessionStorage[k]])); | ||
| }), | ||
| }, | ||
| local: { | ||
| set: vi.fn(async (data: Record<string, unknown>) => { | ||
| Object.assign(mockLocalStorage, data); | ||
| }), | ||
| get: vi.fn(async (keys: string[]) => { | ||
| return Object.fromEntries(keys.map((k) => [k, mockLocalStorage[k]])); | ||
| }), | ||
| remove: vi.fn(async (keys: string[]) => { | ||
| keys.forEach((k) => delete mockLocalStorage[k]); | ||
| }), | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| Object.keys(mockSessionStorage).forEach((k) => delete mockSessionStorage[k]); | ||
| Object.keys(mockLocalStorage).forEach((k) => delete mockLocalStorage[k]); | ||
| }); | ||
|
|
||
| describe("validatePairingParams", () => { | ||
| it("returns no errors for a valid deep-link", () => { | ||
| const errors = validatePairingParams(VALID_HASH); | ||
| expect(errors).toHaveLength(0); | ||
| expect(validatePairingParams(VALID_HASH)).toHaveLength(0); | ||
| }); | ||
|
|
||
| it("returns token-malformed when token is too short", () => { | ||
| const hash = `#token=short&port=65000`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=short&port=65000`).map((e) => e.code); | ||
| expect(codes).toContain("token-malformed"); | ||
| }); | ||
|
|
||
| it("returns port-out-of-range when port is 99999", () => { | ||
| const hash = `#token=${VALID_TOKEN}&port=99999`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=99999`).map((e) => e.code); | ||
| expect(codes).toContain("port-out-of-range"); | ||
| }); | ||
|
|
||
| it("returns both token-malformed AND port-out-of-range", () => { | ||
| const hash = `#token=short&port=99999`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=short&port=99999`).map((e) => e.code); | ||
| expect(codes).toContain("token-malformed"); | ||
| expect(codes).toContain("port-out-of-range"); | ||
| }); | ||
|
|
||
| it("returns missing-params when both are absent", () => { | ||
| const errors = validatePairingParams("#"); | ||
| expect(errors[0].code).toBe("missing-params"); | ||
| expect(validatePairingParams("#")[0].code).toBe("missing-params"); | ||
| }); | ||
|
|
||
| it("rejects decimal ports like 1024.5", () => { | ||
| const hash = `#token=${VALID_TOKEN}&port=1024.5`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=1024.5`).map((e) => e.code); | ||
| expect(codes).toContain("port-out-of-range"); | ||
| }); | ||
|
|
||
| it("rejects hex ports like 0x2000", () => { | ||
| const hash = `#token=${VALID_TOKEN}&port=0x2000`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=${VALID_TOKEN}&port=0x2000`).map((e) => e.code); | ||
| expect(codes).toContain("port-out-of-range"); | ||
| }); | ||
|
|
||
| it("rejects ports with leading spaces", () => { | ||
| const hash = `#token=${VALID_TOKEN}&port= 8080`; | ||
| const codes = validatePairingParams(hash).map((e) => e.code); | ||
| const codes = validatePairingParams(`#token=${VALID_TOKEN}&port= 8080`).map((e) => e.code); | ||
| expect(codes).toContain("port-out-of-range"); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe("storage migration", () => { | ||
| it("token does not appear in chrome.storage.local after migration", async () => { | ||
| mockLocalStorage["mcp_pairing_token_v1"] = VALID_TOKEN; | ||
| mockLocalStorage["mcp_pairing_port_v1"] = "65000"; | ||
|
|
||
| const { migrateLegacyStorage } = await import("../panel/components/MCPPairingPanel"); | ||
| await migrateLegacyStorage(); | ||
|
|
||
| expect(mockLocalStorage["mcp_pairing_token_v1"]).toBeUndefined(); | ||
| expect(mockLocalStorage["mcp_pairing_port_v1"]).toBeUndefined(); | ||
| expect(mockSessionStorage["mcp_pairing_token_v1"]).toBe(VALID_TOKEN); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,6 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useEffect, useState } from "react"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ErrorCode = "token-malformed" | "port-out-of-range" | "missing-params"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ErrorCode = "token-malformed" | "port-out-of-range" | "missing-params" | "storage-failed"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface PairingError { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| code: ErrorCode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -57,29 +57,64 @@ export function validatePairingParams(hash: string): PairingError[] { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return errors; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const TOKEN_KEY = "mcp_pairing_token_v1"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PORT_KEY = "mcp_pairing_port_v1"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function migrateLegacyStorage() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof chrome === "undefined" || !chrome.storage) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const local = await chrome.storage.local.get([TOKEN_KEY, PORT_KEY]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (local[TOKEN_KEY] || local[PORT_KEY]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await chrome.storage.session.set({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [TOKEN_KEY]: local[TOKEN_KEY], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [PORT_KEY]: local[PORT_KEY], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await chrome.storage.local.remove([TOKEN_KEY, PORT_KEY]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+63
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The migration function As a result, the migration will never find the legacy pairing token and port, and existing paired users will lose their pairing state. Additionally, if We should:
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function savePairing(token: string, port: string) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await chrome.storage.session.set({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [TOKEN_KEY]: token, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| [PORT_KEY]: port, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function MCPPairingPanel() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [errors, setErrors] = useState<PairingError[]>([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [paired, setPaired] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const performPairing = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const performPairing = async () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const hash = window.location.hash; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const found = validatePairingParams(hash); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (found.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { token, port } = parseHash(hash); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof chrome !== "undefined" && chrome.storage?.local) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| chrome.storage.local.set({ mcpToken: token, mcpPort: port }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof chrome !== "undefined" && chrome.storage?.session) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await savePairing(token!, port!); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| window.history.replaceState(null, "", window.location.pathname); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setPaired(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setErrors([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setErrors([{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| code: "storage-failed", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| message: "Could not save pairing.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fix: (err as Error).message, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+92
to
105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If We should throw an error if
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setPaired(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setErrors([]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setErrors(found); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setPaired(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| performPairing(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof chrome !== "undefined" && chrome.storage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| migrateLegacyStorage().then(() => performPairing()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| performPairing(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleRetry = () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -110,4 +145,4 @@ export function MCPPairingPanel() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <button onClick={handleRetry}>Retry</button> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test sets up
mockLocalStorageusing the new keys ("mcp_pairing_token_v1"and"mcp_pairing_port_v1") instead of the actual legacy keys ("mcpToken"and"mcpPort"). This masked the bug in the migration function.We should update the test to use the actual legacy keys to ensure the migration is tested correctly.