From ab56dae334ba180ff523ce1db4075ef7acc84f32 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Tue, 19 May 2026 14:17:50 -0700 Subject: [PATCH 1/3] Sanitize serialized library errors Redact credential-like properties and known credential tokens when serializing non-Error values so thrown objects retain useful context without exposing obvious secrets. Co-authored-by: Dobby --- src/errors.ts | 97 +++++++++++++++++++++++++++++++++++++++++++-- test/errors.test.ts | 43 +++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 7f8192a..783c8bb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,29 @@ export class ReadOnlyError extends AzFuncTypeError { } } +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 ensureErrorType(err: unknown): ValidatedError { if (err instanceof Error) { return err; @@ -42,16 +65,84 @@ 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 = safeStringify(err); } else { - message = String(err); + message = sanitizeErrorString(String(err)); } return new Error(message); } } +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); +} + +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 safeStringify(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 isCredentialName(name: string): boolean { + return credentialNameFragments.some((fragment) => name.toLowerCase().includes(fragment)); +} + export function trySetErrorMessage(err: Error, message: string): void { try { err.message = message; diff --git a/test/errors.test.ts b/test/errors.test.ts index eb65e23..164b20e 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -3,7 +3,7 @@ import 'mocha'; import { expect } from 'chai'; -import { ensureErrorType, trySetErrorMessage } from '../src/errors'; +import { ensureErrorType, sanitizeErrorString, trySetErrorMessage } from '../src/errors'; describe('ensureErrorType', () => { it('null', () => { @@ -29,10 +29,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]'); }); From 426028cf5775ac55fb22fc638f38a14b7a7f0771 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Wed, 20 May 2026 00:00:11 -0700 Subject: [PATCH 2/3] Address sanitizer review feedback --- src/errors.ts | 97 ++----------------------------------- src/http/httpProxy.ts | 3 +- src/utils/errorSanitizer.ts | 93 +++++++++++++++++++++++++++++++++++ test/errors.test.ts | 10 +++- 4 files changed, 108 insertions(+), 95 deletions(-) create mode 100644 src/utils/errorSanitizer.ts diff --git a/src/errors.ts b/src/errors.ts index 783c8bb..7ad7d82 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/errorSanitizer'; + export interface AzFuncError { /** * System errors can be tracked in our telemetry @@ -34,29 +36,6 @@ export class ReadOnlyError extends AzFuncTypeError { } } -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 ensureErrorType(err: unknown): ValidatedError { if (err instanceof Error) { return err; @@ -67,7 +46,7 @@ export function ensureErrorType(err: unknown): ValidatedError { } else if (typeof err === 'string') { message = sanitizeErrorString(err); } else if (typeof err === 'object') { - message = safeStringify(err); + message = stringifySanitizedErrorObject(err); } else { message = sanitizeErrorString(String(err)); } @@ -75,77 +54,9 @@ export function ensureErrorType(err: unknown): ValidatedError { } } -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); -} - -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 safeStringify(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 isCredentialName(name: string): boolean { - return credentialNameFragments.some((fragment) => name.toLowerCase().includes(fragment)); -} - 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..6f12225 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/errorSanitizer'; 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/errorSanitizer.ts b/src/utils/errorSanitizer.ts new file mode 100644 index 0000000..1423f3f --- /dev/null +++ b/src/utils/errorSanitizer.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 164b20e..0799d4f 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -3,7 +3,8 @@ import 'mocha'; import { expect } from 'chai'; -import { ensureErrorType, sanitizeErrorString, trySetErrorMessage } from '../src/errors'; +import { ensureErrorType, trySetErrorMessage } from '../src/errors'; +import { sanitizeErrorString } from '../src/utils/errorSanitizer'; describe('ensureErrorType', () => { it('null', () => { @@ -90,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 { From 0187244ad3d4f3eae0794b5a0ae6c3604a0724c9 Mon Sep 17 00:00:00 2001 From: Tsuyoshi Ushio Date: Fri, 22 May 2026 11:07:27 -0700 Subject: [PATCH 3/3] Rename credential sanitizer module Co-authored-by: Dobby --- src/errors.ts | 2 +- src/http/httpProxy.ts | 2 +- src/utils/{errorSanitizer.ts => credentialSanitizer.ts} | 0 test/errors.test.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/utils/{errorSanitizer.ts => credentialSanitizer.ts} (100%) diff --git a/src/errors.ts b/src/errors.ts index 7ad7d82..0c8bcf9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,7 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT License. -import { sanitizeErrorString, stringifySanitizedErrorObject } from './utils/errorSanitizer'; +import { sanitizeErrorString, stringifySanitizedErrorObject } from './utils/credentialSanitizer'; export interface AzFuncError { /** diff --git a/src/http/httpProxy.ts b/src/http/httpProxy.ts index 6f12225..744b12a 100644 --- a/src/http/httpProxy.ts +++ b/src/http/httpProxy.ts @@ -6,7 +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/errorSanitizer'; +import { sanitizeErrorString } from '../utils/credentialSanitizer'; import { nonNullProp } from '../utils/nonNull'; import { workerSystemLog } from '../utils/workerSystemLog'; import { HttpResponse } from './HttpResponse'; diff --git a/src/utils/errorSanitizer.ts b/src/utils/credentialSanitizer.ts similarity index 100% rename from src/utils/errorSanitizer.ts rename to src/utils/credentialSanitizer.ts diff --git a/test/errors.test.ts b/test/errors.test.ts index 0799d4f..f2333f1 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -4,7 +4,7 @@ import 'mocha'; import { expect } from 'chai'; import { ensureErrorType, trySetErrorMessage } from '../src/errors'; -import { sanitizeErrorString } from '../src/utils/errorSanitizer'; +import { sanitizeErrorString } from '../src/utils/credentialSanitizer'; describe('ensureErrorType', () => { it('null', () => {