harden: debug mode logs only a request's origin and JSON-RPC method (closes #410)
With debug mode on, debugFetch logged every request's full URL and body, so an RPC endpoint with an API key in its path or query string printed that key to the console on every request. It now logs the HTTP method, the URL's origin and, for a JSON-RPC body, the method name. The balance refresh and token lookup log the RPC endpoint by its origin too. Failed RPC calls print ethers' short message, since its full message for an HTTP error carries the request URL. A failed endpoint check in settings prints the endpoint's origin, since fetch's error for a URL with a user name and password carries the whole URL. The README's DEBUG Mode Policy says what debug mode logs. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// What debugFetch writes to the console in debug mode.
|
||||
//
|
||||
// RPC providers put the API key in the URL's path or query string, and the
|
||||
// debug log used to print the whole URL and request body, so turning debug
|
||||
// mode on wrote the key to the console
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
|
||||
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
|
||||
// the request.
|
||||
|
||||
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
setRuntimeDebug(false);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
|
||||
setRuntimeDebug(true);
|
||||
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
|
||||
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
|
||||
|
||||
await debugFetch(
|
||||
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "eth_chainId",
|
||||
params: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const logged = consoleLog.mock.calls.flat().join(" ");
|
||||
expect(logged).not.toContain("PATHKEY123");
|
||||
expect(logged).not.toContain("QUERYTOKEN456");
|
||||
expect(logged).toContain("https://rpc.example.invalid");
|
||||
expect(logged).toContain("eth_chainId");
|
||||
});
|
||||
|
||||
test("the origin leaves out a user name and password in the URL", () => {
|
||||
expect(
|
||||
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
|
||||
).toBe("https://rpc.example.invalid");
|
||||
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
|
||||
"wss://rpc.example.invalid:8546",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
// What reaches the console when the RPC endpoint answers with an HTTP error.
|
||||
//
|
||||
// RPC providers put the API key in the endpoint URL's path or query string.
|
||||
// When the endpoint answers with an HTTP error (a wrong or expired key, a rate
|
||||
// limit, a server error), the error ethers throws carries the full request URL
|
||||
// in its message, so a line logging that message printed the key
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410). Those lines log the
|
||||
// error's short message, which names the HTTP status and not the URL.
|
||||
//
|
||||
// The real ethers provider runs; only its HTTP transport is replaced, by one
|
||||
// that answers every request with 401 Unauthorized. Debug mode is on, so
|
||||
// every log level is printed.
|
||||
|
||||
const { FetchRequest } = require("ethers");
|
||||
const {
|
||||
getProvider,
|
||||
lookupTokenInfo,
|
||||
refreshBalances,
|
||||
} = require("../src/shared/balances");
|
||||
const { getFullWarnings } = require("../src/shared/addressWarnings");
|
||||
const { resolveEnsName } = require("../src/shared/ens");
|
||||
const { setRuntimeDebug } = require("../src/shared/log");
|
||||
|
||||
const RPC_URL = "https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let printed;
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeDebug(true);
|
||||
printed = [];
|
||||
for (const method of ["log", "warn", "error"]) {
|
||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||
printed.push(args.map(String).join(" "));
|
||||
});
|
||||
}
|
||||
FetchRequest.registerGetUrl(async () => ({
|
||||
statusCode: 401,
|
||||
statusMessage: "Unauthorized",
|
||||
headers: {},
|
||||
body: new Uint8Array(),
|
||||
}));
|
||||
// The explorer requests the balance refresh makes go nowhere.
|
||||
globalThis.fetch = jest.fn(async () => {
|
||||
throw new Error("tests must not perform network requests");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
|
||||
globalThis.fetch = realFetch;
|
||||
setRuntimeDebug(false);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// The line carrying `label` was printed and names the HTTP status, and nothing
|
||||
// printed carries the key.
|
||||
function expectFailureLoggedWithoutKey(label) {
|
||||
const line = printed.find((text) => text.includes(label));
|
||||
expect(line).toContain("401");
|
||||
const all = printed.join("\n");
|
||||
expect(all).not.toContain("PATHKEY123");
|
||||
expect(all).not.toContain("QUERYTOKEN456");
|
||||
}
|
||||
|
||||
test("ethers puts the URL in the error message, not in the short message", async () => {
|
||||
const provider = getProvider(RPC_URL, "mainnet");
|
||||
const error = await provider.getCode(ADDRESS).catch((e) => e);
|
||||
expect(error.message).toContain("PATHKEY123");
|
||||
expect(error.shortMessage).not.toContain("PATHKEY123");
|
||||
});
|
||||
|
||||
test("the recipient checks before a send", async () => {
|
||||
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
|
||||
expectFailureLoggedWithoutKey("contract check failed");
|
||||
expectFailureLoggedWithoutKey("tx count check failed");
|
||||
});
|
||||
|
||||
test("the ENS reverse lookup", async () => {
|
||||
expect(await resolveEnsName(ADDRESS, RPC_URL, "mainnet")).toBeNull();
|
||||
expectFailureLoggedWithoutKey("ENS reverse lookup failed");
|
||||
});
|
||||
|
||||
test("the balance refresh", async () => {
|
||||
const wallets = [{ addresses: [{ address: ADDRESS }] }];
|
||||
await refreshBalances(
|
||||
wallets,
|
||||
RPC_URL,
|
||||
"https://explorer.example.invalid/api/v2",
|
||||
[],
|
||||
"mainnet",
|
||||
);
|
||||
expectFailureLoggedWithoutKey("ETH balance failed");
|
||||
expectFailureLoggedWithoutKey("ENS reverse failed");
|
||||
});
|
||||
|
||||
// The lookup's first line, at debug level, names the RPC endpoint; the check
|
||||
// of everything printed covers it too.
|
||||
test("the token lookup", async () => {
|
||||
await expect(lookupTokenInfo(ADDRESS, RPC_URL, "mainnet")).rejects.toThrow(
|
||||
"Not a valid ERC-20 token",
|
||||
);
|
||||
expectFailureLoggedWithoutKey("symbol() failed:");
|
||||
});
|
||||
@@ -66,6 +66,7 @@ jest.mock("../src/shared/log", () => ({
|
||||
status: 200,
|
||||
json: async () => mockExplorer.items,
|
||||
})),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// What reaches the console when an endpoint check in Settings fails.
|
||||
//
|
||||
// fetch refuses a URL with a user name and password in it, or one it cannot
|
||||
// parse, with an error whose message carries the whole URL: the password, and
|
||||
// any API key in the path or query string. The checks behind the RPC and
|
||||
// Blockscout Save buttons printed that message
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/410); they now name the
|
||||
// endpoint by its origin.
|
||||
//
|
||||
// The real fetch runs; it throws before making any request. Debug mode is on,
|
||||
// so every log level is printed.
|
||||
|
||||
const SECRETS = ["SECRETPASS789", "PATHKEY123", "QUERYTOKEN456"];
|
||||
const RPC_WITH_PASSWORD =
|
||||
"https://user:SECRETPASS789@rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
// Port 99999 is out of range, so the URL does not parse.
|
||||
const RPC_UNPARSEABLE =
|
||||
"https://rpc.example.invalid:99999/v3/PATHKEY123?token=QUERYTOKEN456";
|
||||
const BLOCKSCOUT_WITH_PASSWORD =
|
||||
"https://user:SECRETPASS789@explorer.example.invalid/PATHKEY123/api/v2";
|
||||
|
||||
const SAVED_RPC = "https://saved-rpc.example.invalid";
|
||||
const SAVED_BLOCKSCOUT = "https://saved-explorer.example.invalid/api/v2";
|
||||
|
||||
let elements;
|
||||
let flashes;
|
||||
let printed;
|
||||
let state;
|
||||
|
||||
// A stand-in for one DOM node: enough of an element for init() to set
|
||||
// properties on it and hang listeners off it.
|
||||
function fakeElement() {
|
||||
return {
|
||||
value: "",
|
||||
checked: false,
|
||||
textContent: "",
|
||||
href: "",
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {} },
|
||||
listeners: {},
|
||||
addEventListener(event, handler) {
|
||||
this.listeners[event] = handler;
|
||||
},
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function element(id) {
|
||||
return (elements[id] ||= fakeElement());
|
||||
}
|
||||
|
||||
function loadSettingsView() {
|
||||
elements = {};
|
||||
flashes = [];
|
||||
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock("../src/popup/views/helpers", () => ({
|
||||
$: element,
|
||||
showView: () => {},
|
||||
updateDebugBanner: () => {},
|
||||
showFlash: (msg) => flashes.push(msg),
|
||||
escapeHtml: (s) => s,
|
||||
flashCopyFeedback: () => {},
|
||||
goBack: () => {},
|
||||
pushCurrentView: () => {},
|
||||
onViewLeave: () => {},
|
||||
VIEWS: [],
|
||||
}));
|
||||
|
||||
state = require("../src/shared/state").state;
|
||||
state.rpcUrl = SAVED_RPC;
|
||||
state.blockscoutUrl = SAVED_BLOCKSCOUT;
|
||||
require("../src/shared/log").setRuntimeDebug(true);
|
||||
|
||||
require("../src/popup/views/settings").init({});
|
||||
}
|
||||
|
||||
async function save(fieldId, buttonId, typed) {
|
||||
element(fieldId).value = typed;
|
||||
await element(buttonId).listeners.click();
|
||||
}
|
||||
|
||||
// The check failed, nothing was saved, and nothing printed carries the
|
||||
// password or the key.
|
||||
function expectFailedWithoutSecrets(label) {
|
||||
expect(flashes).toContain("Could not reach endpoint.");
|
||||
expect(state.rpcUrl).toBe(SAVED_RPC);
|
||||
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
|
||||
const line = printed.find((text) => text.includes(label));
|
||||
expect(line).toBeDefined();
|
||||
const all = printed.join("\n");
|
||||
for (const secret of SECRETS) {
|
||||
expect(all).not.toContain(secret);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
printed = [];
|
||||
for (const method of ["log", "warn", "error"]) {
|
||||
jest.spyOn(console, method).mockImplementation((...args) => {
|
||||
printed.push(args.map(String).join(" "));
|
||||
});
|
||||
}
|
||||
globalThis.chrome = {
|
||||
runtime: { sendMessage: () => {} },
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.dontMock("../src/popup/views/helpers");
|
||||
delete globalThis.chrome;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("fetch puts the whole URL in the error it throws for such a URL", async () => {
|
||||
for (const url of [RPC_WITH_PASSWORD, RPC_UNPARSEABLE]) {
|
||||
const error = await fetch(url).catch((e) => e);
|
||||
expect(error.message).toContain("PATHKEY123");
|
||||
}
|
||||
});
|
||||
|
||||
test("the RPC check of a URL with a user name and password", async () => {
|
||||
loadSettingsView();
|
||||
await save("settings-rpc", "btn-save-rpc", RPC_WITH_PASSWORD);
|
||||
const line = expectFailedWithoutSecrets("RPC validation fetch failed");
|
||||
expect(line).toContain("https://rpc.example.invalid");
|
||||
});
|
||||
|
||||
test("the RPC check of a URL that does not parse", async () => {
|
||||
loadSettingsView();
|
||||
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
|
||||
expectFailedWithoutSecrets("RPC validation fetch failed");
|
||||
});
|
||||
|
||||
test("the Blockscout check of a URL with a user name and password", async () => {
|
||||
loadSettingsView();
|
||||
await save(
|
||||
"settings-blockscout",
|
||||
"btn-save-blockscout",
|
||||
BLOCKSCOUT_WITH_PASSWORD,
|
||||
);
|
||||
const line = expectFailedWithoutSecrets("Blockscout validation failed");
|
||||
expect(line).toContain("https://explorer.example.invalid");
|
||||
});
|
||||
@@ -44,6 +44,7 @@ jest.mock("../src/shared/log", () => ({
|
||||
errorf: () => {},
|
||||
},
|
||||
debugFetch: jest.fn(),
|
||||
urlOrigin: () => "",
|
||||
setRuntimeDebug: () => {},
|
||||
isDebug: () => false,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user