diff --git a/src/errors.ts b/src/errors.ts index 7f8192a..0c8bcf9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. +import { sanitizeErrorString, stringifySanitizedErrorObject } from './utils/credentialSanitizer'; + export interface AzFuncError { /** * System errors can be tracked in our telemetry @@ -42,11 +44,11 @@ export function ensureErrorType(err: unknown): ValidatedError { if (err === undefined || err === null) { message = 'Unknown error'; } else if (typeof err === 'string') { - message = err; + message = sanitizeErrorString(err); } else if (typeof err === 'object') { - message = JSON.stringify(err); + message = stringifySanitizedErrorObject(err); } else { - message = String(err); + message = sanitizeErrorString(String(err)); } return new Error(message); } @@ -54,7 +56,7 @@ export function ensureErrorType(err: unknown): ValidatedError { export function trySetErrorMessage(err: Error, message: string): void { try { - err.message = message; + err.message = sanitizeErrorString(message); } catch { // If we can't set the message, we'll keep the error as is } diff --git a/src/http/httpProxy.ts b/src/http/httpProxy.ts index b1c683b..744b12a 100644 --- a/src/http/httpProxy.ts +++ b/src/http/httpProxy.ts @@ -6,6 +6,7 @@ import { EventEmitter } from 'events'; import * as http from 'http'; import * as net from 'net'; import { AzFuncSystemError, ensureErrorType } from '../errors'; +import { sanitizeErrorString } from '../utils/credentialSanitizer'; import { nonNullProp } from '../utils/nonNull'; import { workerSystemLog } from '../utils/workerSystemLog'; import { HttpResponse } from './HttpResponse'; @@ -103,7 +104,7 @@ export async function setupHttpProxy(): Promise { server.on('error', (err) => { err = ensureErrorType(err); - workerSystemLog('error', `Http proxy error: ${err.stack || err.message}`); + workerSystemLog('error', `Http proxy error: ${sanitizeErrorString(err.stack || err.message)}`); }); server.listen(() => { diff --git a/src/utils/credentialSanitizer.ts b/src/utils/credentialSanitizer.ts new file mode 100644 index 0000000..1423f3f --- /dev/null +++ b/src/utils/credentialSanitizer.ts @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. + +const hiddenCredential = '[Hidden Credential]'; +const circularReference = '[Circular]'; +const credentialNameFragments = ['password', 'pwd', 'key', 'secret', 'token', 'sas']; +const credentialTokens = [ + 'Token=', + 'DefaultEndpointsProtocol=http', + 'AccountKey=', + 'Data Source=', + 'Server=', + 'Password=', + 'pwd=', + '&sig=', + '&sig=', + '?sig=', + 'SharedAccessKey=', + '&code=', + '&code=', + '?code=', + '/code=', + 'key=', +]; +const urlCredentialPattern = /\b([a-zA-Z]+):\/\/([^:/\s]+):([^@/\s]+)@([^:/\s]+):([0-9]+)\b/g; + +export function sanitizeErrorString(input: string): string { + if (!input) { + return input; + } + + let sanitized = input; + for (const token of credentialTokens) { + sanitized = replaceCredentialToken(sanitized, token); + } + + return sanitized.replace(urlCredentialPattern, hiddenCredential); +} + +export function stringifySanitizedErrorObject(value: object): string { + const seen = new WeakSet(); + return JSON.stringify(value, (key, val: unknown) => { + if (isCredentialName(key)) { + return hiddenCredential; + } + + if (typeof val === 'string') { + return sanitizeErrorString(val); + } + + if (typeof val === 'bigint') { + return val.toString(); + } + + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) { + return circularReference; + } + seen.add(val); + } + + return val; + }); +} + +function replaceCredentialToken(input: string, token: string): string { + const lowerInput = input.toLowerCase(); + const lowerToken = token.toLowerCase(); + let startIndex = lowerInput.indexOf(lowerToken); + if (startIndex === -1) { + return input; + } + + let sanitized = ''; + let searchOffset = 0; + while (startIndex !== -1) { + const credentialEnd = findCredentialEnd(input, startIndex); + sanitized += input.substring(searchOffset, startIndex) + hiddenCredential; + searchOffset = credentialEnd; + startIndex = lowerInput.indexOf(lowerToken, searchOffset); + } + + return sanitized + input.substring(searchOffset); +} + +function findCredentialEnd(input: string, startIndex: number): number { + const terminatorIndex = input.substring(startIndex).search(/[<"'\r\n]/); + return terminatorIndex === -1 ? input.length : startIndex + terminatorIndex; +} + +function isCredentialName(name: string): boolean { + return credentialNameFragments.some((fragment) => name.toLowerCase().includes(fragment)); +} diff --git a/test/errors.test.ts b/test/errors.test.ts index eb65e23..f2333f1 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -4,6 +4,7 @@ import 'mocha'; import { expect } from 'chai'; import { ensureErrorType, trySetErrorMessage } from '../src/errors'; +import { sanitizeErrorString } from '../src/utils/credentialSanitizer'; describe('ensureErrorType', () => { it('null', () => { @@ -29,10 +30,51 @@ describe('ensureErrorType', () => { validateError(ensureErrorType(''), ''); }); + it('string with credential token', () => { + validateError(ensureErrorType('AccountKey=abc123;EndpointSuffix=core.windows.net'), '[Hidden Credential]'); + }); + + it('sanitizes url credentials', () => { + expect(sanitizeErrorString('failed to connect to https://user:pass@example.com:443')).to.equal( + 'failed to connect to [Hidden Credential]' + ); + }); + + it('preserves stack text after credential tokens', () => { + expect(sanitizeErrorString('failed AccountKey=abc123\n at invokeFunction')).to.equal( + 'failed [Hidden Credential]\n at invokeFunction' + ); + }); + it('object', () => { validateError(ensureErrorType({ test: '2' }), '{"test":"2"}'); }); + it('object with credential properties', () => { + validateError( + ensureErrorType({ + message: 'Auth failed', + secretKey: 'secret-value', + nested: { password: 'password-value', visible: 'safe' }, + }), + '{"message":"Auth failed","secretKey":"[Hidden Credential]","nested":{"password":"[Hidden Credential]","visible":"safe"}}' + ); + }); + + it('object with credential tokens in string values', () => { + validateError( + ensureErrorType({ connectionString: 'AccountKey=abc123;EndpointSuffix=core.windows.net' }), + '{"connectionString":"[Hidden Credential]"}' + ); + }); + + it('object with circular reference', () => { + const err: Record = { message: 'failed' }; + err.self = err; + + validateError(ensureErrorType(err), '{"message":"failed","self":"[Circular]"}'); + }); + it('array', () => { validateError(ensureErrorType([1, 2]), '[1,2]'); }); @@ -49,6 +91,13 @@ describe('ensureErrorType', () => { expect(actualError.message).to.equal('modified message'); }); + it('sanitizes modified error message', () => { + const actualError = new Error('test2'); + trySetErrorMessage(actualError, 'AccountKey=abc123'); + + expect(actualError.message).to.equal('[Hidden Credential]'); + }); + it('readonly error', () => { class ReadOnlyError extends Error { get message(): string {