The JavaScript console is a very useful debugging tool, but it can also introduce a very simple security risk into your application: console
method calls that aren't removed before code is deployed can leak sensitive information to the browser. That's why many developers use a linter to warn about console
method calls and other issues. (On RedBit's web team, we use husky to run the linter on a pre-commit hook so that console
method calls are never committed by accident. We've found that many small bugs can be avoided by making linting mandatory before committing code.)
The problem is that there are times when you want to leave console
method calls in place. You can use eslint-disable
comments to bypass the linter, but you still run the risk of leaking sensitive information if you forget to clean up before you deploy. You could also wrap your console
method calls in a condition that checks a debug or environment flag and only executes the calls in development, but that's a bit awkward. It's also easy to forget so your users' security will depend on your memory. That certainly isn't a good strategy given that we all make mistakes from time to time.
A better solution would be to integrate the debug or environment condition into the console itself. We can do this by replacing the browser's window.console
object with a modified version of the same. Example 1 shows the function that does this.
Example 1
// console.js
export const createDebugConsole = (consoleObj, options = {}) => {
const nextConsoleObj = { ...consoleObj };
const { enabled = true } = options;
for (let key in nextConsoleObj) {
/* eslint-disable no-prototype-builtins */
if (
nextConsoleObj.hasOwnProperty(key) &&
typeof nextConsoleObj[key] === 'function'
) {
const func = nextConsoleObj[key];
nextConsoleObj[key] = function () {
if (enabled) {
func.apply(nextConsoleObj, arguments);
}
};
}
/* eslint-enable no-prototype-builtins */
}
return nextConsoleObj;
};
Notice that the createDebugConsole
accepts the console object as an argument, rather than assuming it to be in scope. The function is designed this way for two reasons. First, it eliminates a dependency on the global scope which makes the function easier to unit test. Second, it makes it possible to use the function in non-browser environments that use a browser-like console
object that may not be attached to the window
global.
Let's examine this function line by line:
- Accept the console object and a hash of options as arguments.
- Make a shallow copy of the console object as
nextConsoleObj
, to avoid modifying the original. - Destructure the
enabled
boolean from the options hash. The default value istrue
, which means that the new console will allow logging unless you pass{ enabled: false }
in the options hash. - Enumerate the properties of the copied console object. For each key that is owned by the object (i.e. not inherited) and whose value is a function, take a reference to the value (
func
) and then replace it with a new function. If theenabled
option istrue
, the new function callsfunc.apply
with the new console object and the new function's arguments. (Note that we're using the traditionalfunction
declaration here, rather than an ES6 arrow function, to ensure that we have access to thearguments
array.) If theenabled
option isfalse
, the new function does nothing. Any additional properties of the console object that are not functions will be left unchanged. - Return the new console object.
(Depending on your linter rules, you may or may not need the /* eslint-disable no-prototype-builtins */
and /* eslint-enable no-prototype-builtins */
comments.)
Integration
Example 2 shows how to use the function to replace the browser's default console with our new debug console. In this case, we're using process.env.NODE_ENV
to enable logging in non-production builds. (This assumes that your build process replaces process.env.NODE_ENV
with the value of the NODE_ENV
environment variable.) You could also set the enabled
option based on a value from a dotenv file or any other source of configuration.
Example 2
// index.js
import { createDebugConsole } from './console';
window.console = createDebugConsole(window.console, {
enabled: process.env.NODE_ENV !== 'production'
});
console.log("This won't log in production!");
Call createDebugConsole
once in your application's main index.js
file or wherever you do other setup work. You can now use the global console
object normally. If the condition that sets the enabled
value is true
, the console
methods will behave normally. It it's false
the methods will return without doing anything. You can now be more confident that your production applications won't leak data to the console.
You could also assign the return value of createDebugConsole
to a new constant in case you need to leave the window.console
global untouched, or if you just don't want to use the global console, as shown in Example 3:
Example 3
import { createDebugConsole } from './console';
const myConsole = createDebugConsole(window.console, {
enabled: process.env.NODE_ENV !== 'production'
});
myConsole.log("This won't log in production!");
The approach you take should depend on your application and personal preferences. I prefer to use the window.console
global for two reasons:
- It allows me to use the console without having to import anything
- The linter will still complain if I leave
console
method calls in my code without explicitly allowing them witheslint-disable no-console
comments. This helps reduce unnecessary logging.
Testing
The console created by createDebugConsole
will not be active in unit tests, so you won't be able to use it to prevent logging in your test environment. If you use jest you can silence console
methods with the jest CLI.
Here is a short jest
suite that you can use to test createDebugConsole
:
import { createDebugConsole } from './console';
const getMockConsole = () => ({
assert: jest.fn(),
clear: jest.fn(),
count: jest.fn(),
countReset: jest.fn(),
debug: jest.fn(),
dir: jest.fn(),
dirxml: jest.fn(),
error: jest.fn(),
group: jest.fn(),
groupCollapsed: jest.fn(),
groupEnd: jest.fn(),
info: jest.fn(),
log: jest.fn(),
profile: jest.fn(),
profileEnd: jest.fn(),
table: jest.fn(),
time: jest.fn(),
timeEnd: jest.fn(),
timeLog: jest.fn(),
timeStamp: jest.fn(),
trace: jest.fn(),
warn: jest.fn(),
});
test('createDebugConsole returns a copy of the input console', () => {
const consoleObj = getMockConsole();
const nextConsoleObj = createDebugConsole(consoleObj);
expect(consoleObj === nextConsoleObj).toBe(false);
for (let key in consoleObj) {
// eslint-disable-next-line no-prototype-builtins
if (consoleObj.hasOwnProperty(key)) {
expect(consoleObj[key] === nextConsoleObj[key]).toBe(false);
}
}
});
test('When a function is called on the debug console returned by createDebugConsole, the same function is called on the original console with the same arguments if the debug console is enabled', () => {
const consoleObj = getMockConsole();
const nextConsoleObj = createDebugConsole(consoleObj, { enabled: true });
const args = ['test', 'test2', 'test3'];
for (let key in nextConsoleObj) {
// eslint-disable-next-line no-prototype-builtins
if (nextConsoleObj.hasOwnProperty(key)) {
nextConsoleObj[key](...args);
expect(consoleObj[key]).toHaveBeenCalledTimes(1);
expect(consoleObj[key]).toHaveBeenCalledWith(...args);
}
}
});
test('When a function is called on the debug console returned by createDebugConsole, the same function is not called on the original console if the debug console is disabled', () => {
const consoleObj = getMockConsole();
const nextConsoleObj = createDebugConsole(consoleObj, { enabled: false });
const args = ['test', 'test2', 'test3'];
for (let key in nextConsoleObj) {
// eslint-disable-next-line no-prototype-builtins
if (nextConsoleObj.hasOwnProperty(key)) {
nextConsoleObj[key](...args);
expect(consoleObj[key]).not.toHaveBeenCalled();
}
}
});
Top comments (0)