Skip to content

Commit 249ec7e

Browse files
committed
refactor(core): Fix sanitizer
1 parent 005f3e1 commit 249ec7e

File tree

8 files changed

+208
-165
lines changed

8 files changed

+208
-165
lines changed

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { HawkUserManager } from './users/hawk-user-manager';
33
export type { Logger, LogType } from './logger/logger';
44
export { isLoggerSet, setLogger, resetLogger, log } from './logger/logger';
55
export { Sanitizer } from './modules/sanitizer';
6+
export type { SanitizerTypeHandler } from './modules/sanitizer';
67
export { StackParser } from './modules/stack-parser';
78
export { buildElementSelector } from './utils/selector';
89
export type { Transport } from './transports/transport';

packages/core/src/modules/fetch-timer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { log } from '@hawk.so/core';
1+
import { log } from '../logger/logger';
22

33
/**
44
* Sends AJAX request and wait for some time.

packages/core/src/modules/sanitizer.ts

Lines changed: 54 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,28 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { isPlainObject } from '../utils/validation';
33

4+
/**
5+
* Custom type handler for Sanitizer.
6+
*
7+
* Allows user to register their own formatters from external packages.
8+
*/
9+
export interface SanitizerTypeHandler {
10+
/**
11+
* Checks if this handler should be applied to given value
12+
*
13+
* @returns `true`
14+
*/
15+
check: (target: any) => boolean;
16+
17+
/**
18+
* Formats the value into a sanitized representation
19+
*/
20+
format: (target: any) => any;
21+
}
22+
423
/**
524
* This class provides methods for preparing data to sending to Hawk
625
* - trim long strings
7-
* - represent html elements like <div ...> as "<div>" instead of "{}"
826
* - represent big objects as "<big object>"
927
* - represent class as <class SomeClass> or <instance of SomeClass>
1028
*/
@@ -30,6 +48,13 @@ export class Sanitizer {
3048
*/
3149
private static readonly maxArrayLength: number = 10;
3250

51+
/**
52+
* Custom type handlers registered via {@link registerHandler}.
53+
*
54+
* Checked in {@link sanitize} before built-in type checks.
55+
*/
56+
private static readonly customHandlers: SanitizerTypeHandler[] = [];
57+
3358
/**
3459
* Check if passed variable is an object
3560
*
@@ -39,6 +64,17 @@ export class Sanitizer {
3964
return isPlainObject(target);
4065
}
4166

67+
/**
68+
* Register a custom type handler.
69+
* Handlers are checked before built-in type checks, in reverse registration order
70+
* (last registered = highest priority).
71+
*
72+
* @param handler - handler to register
73+
*/
74+
public static registerHandler(handler: SanitizerTypeHandler): void {
75+
Sanitizer.customHandlers.unshift(handler);
76+
}
77+
4278
/**
4379
* Apply sanitizing for array/object/primitives
4480
*
@@ -62,19 +98,21 @@ export class Sanitizer {
6298
*/
6399
if (Sanitizer.isArray(data)) {
64100
return this.sanitizeArray(data, depth + 1, seen);
101+
}
65102

66-
/**
67-
* If value is an Element, format it as string with outer HTML
68-
* HTMLDivElement -> "<div ...></div>"
69-
*/
70-
} else if (Sanitizer.isElement(data)) {
71-
return Sanitizer.formatElement(data);
103+
// Check additional handlers provided by env-specific modules or users
104+
// to sanitize some additional cases (e.g. specific object types)
105+
for (const handler of Sanitizer.customHandlers) {
106+
if (handler.check(data)) {
107+
return handler.format(data);
108+
}
109+
}
72110

73-
/**
74-
* If values is a not-constructed class, it will be formatted as "<class SomeClass>"
75-
* class Editor {...} -> <class Editor>
76-
*/
77-
} else if (Sanitizer.isClassPrototype(data)) {
111+
/**
112+
* If values is a not-constructed class, it will be formatted as "<class SomeClass>"
113+
* class Editor {...} -> <class Editor>
114+
*/
115+
if (Sanitizer.isClassPrototype(data)) {
78116
return Sanitizer.formatClassPrototype(data);
79117

80118
/**
@@ -133,7 +171,9 @@ export class Sanitizer {
133171
* @param depth - current depth of recursion
134172
* @param seen - Set of already seen objects to prevent circular references
135173
*/
136-
private static sanitizeObject(data: { [key: string]: any }, depth: number, seen: WeakSet<object>): Record<string, any> | '<deep object>' | '<big object>' {
174+
private static sanitizeObject(data: {
175+
[key: string]: any
176+
}, depth: number, seen: WeakSet<object>): Record<string, any> | '<deep object>' | '<big object>' {
137177
/**
138178
* If the maximum depth is reached, return a placeholder
139179
*/
@@ -207,24 +247,6 @@ export class Sanitizer {
207247
return typeof target === 'string';
208248
}
209249

210-
/**
211-
* Return string representation of the object type
212-
*
213-
* @param object - object to get type
214-
*/
215-
private static typeOf(object: any): string {
216-
return Object.prototype.toString.call(object).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
217-
}
218-
219-
/**
220-
* Check if passed variable is an HTML Element
221-
*
222-
* @param target - variable to check
223-
*/
224-
private static isElement(target: any): boolean {
225-
return target instanceof Element;
226-
}
227-
228250
/**
229251
* Return name of a passed class
230252
*
@@ -250,31 +272,12 @@ export class Sanitizer {
250272
*/
251273
private static trimString(target: string): string {
252274
if (target.length > Sanitizer.maxStringLen) {
253-
return target.substr(0, Sanitizer.maxStringLen) + '…';
275+
return target.substring(0, Sanitizer.maxStringLen) + '…';
254276
}
255277

256278
return target;
257279
}
258280

259-
/**
260-
* Represent HTML Element as string with it outer-html
261-
* HTMLDivElement -> "<div ...></div>"
262-
*
263-
* @param target - variable to format
264-
*/
265-
private static formatElement(target: Element): string {
266-
/**
267-
* Also, remove inner HTML because it can be BIG
268-
*/
269-
const innerHTML = target.innerHTML;
270-
271-
if (innerHTML) {
272-
return target.outerHTML.replace(target.innerHTML, '…');
273-
}
274-
275-
return target.outerHTML;
276-
}
277-
278281
/**
279282
* Represent not-constructed class as "<class SomeClass>"
280283
*
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { Sanitizer } from '../../src';
3+
4+
describe('Sanitizer', () => {
5+
describe('isObject', () => {
6+
it('should return true for a plain object', () => {
7+
expect(Sanitizer.isObject({})).toBe(true);
8+
});
9+
10+
it('should return false for an array', () => {
11+
expect(Sanitizer.isObject([])).toBe(false);
12+
});
13+
14+
it('should return false for a string', () => {
15+
expect(Sanitizer.isObject('x')).toBe(false);
16+
});
17+
18+
it('should return false for a boolean', () => {
19+
expect(Sanitizer.isObject(true)).toBe(false);
20+
});
21+
22+
it('should return false for null', () => {
23+
expect(Sanitizer.isObject(null)).toBe(false);
24+
});
25+
26+
it('should return false for undefined', () => {
27+
expect(Sanitizer.isObject(undefined)).toBe(false);
28+
});
29+
});
30+
31+
describe('sanitize', () => {
32+
it('should pass through strings within the length limit', () => {
33+
expect(Sanitizer.sanitize('hello')).toBe('hello');
34+
});
35+
36+
it('should trim strings longer than maxStringLen', () => {
37+
const long = 'a'.repeat(201);
38+
const result = Sanitizer.sanitize(long);
39+
40+
expect(result).toBe('a'.repeat(200) + '…');
41+
});
42+
43+
it('should pass through short arrays unchanged', () => {
44+
expect(Sanitizer.sanitize([1, 2, 3])).toEqual([1, 2, 3]);
45+
});
46+
47+
it('should truncate arrays over maxArrayLength items and append placeholder', () => {
48+
const arr = Array.from({ length: 12 }, (_, i) => i);
49+
const result = Sanitizer.sanitize(arr);
50+
51+
expect(result).toHaveLength(11);
52+
expect(result[10]).toBe('<2 more items...>');
53+
});
54+
55+
it('should sanitize nested objects recursively', () => {
56+
const longStr = 'a'.repeat(201);
57+
const longArr = Array.from({ length: 12 }, (_, i) => i);
58+
const obj = {
59+
foo: 'x',
60+
bar: longStr,
61+
baz: longArr
62+
}
63+
const result = Sanitizer.sanitize(obj);
64+
65+
expect(result.foo).toBe('x');
66+
expect(result.bar).toBe('a'.repeat(200) + '…');
67+
expect(result.baz).toHaveLength(11);
68+
expect(result.baz[10]).toBe('<2 more items...>');
69+
});
70+
71+
it('should replace objects with more than 20 keys with placeholder', () => {
72+
const big: Record<string, number> = {};
73+
74+
for (let i = 0; i < 21; i++) {
75+
big[`k${i}`] = i;
76+
}
77+
78+
expect(Sanitizer.sanitize(big)).toBe('<big object>');
79+
});
80+
81+
it('should replace deeply nested objects with placeholder', () => {
82+
const deep = { a: { b: { c: { d: { e: { f: 'bottom' } } } } } };
83+
const result = Sanitizer.sanitize(deep);
84+
85+
expect(result.a.b.c.d.e).toBe('<deep object>');
86+
});
87+
88+
it('should format a class (not constructed) as "<class Name>"', () => {
89+
class Foo {}
90+
91+
expect(Sanitizer.sanitize(Foo)).toBe('<class Foo>');
92+
});
93+
94+
it('should format a class instance as "<instance of Name>"', () => {
95+
class Foo {}
96+
97+
expect(Sanitizer.sanitize(new Foo())).toBe('<instance of Foo>');
98+
});
99+
100+
it('should replace circular references with placeholder', () => {
101+
const obj: any = { a: 1 };
102+
103+
obj.self = obj;
104+
105+
const result = Sanitizer.sanitize(obj);
106+
107+
expect(result.self).toBe('<circular>');
108+
});
109+
110+
it.each([
111+
{ label: 'number', value: 42 },
112+
{ label: 'boolean', value: true },
113+
{ label: 'null', value: null },
114+
])('should pass through $label primitives unchanged', ({ value }) => {
115+
expect(Sanitizer.sanitize(value)).toBe(value);
116+
});
117+
});
118+
});

packages/core/tests/utils/validation.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it, expect, vi } from 'vitest';
2-
import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '@hawk.so/core';
2+
import { validateUser, validateContext, isValidEventPayload, isValidBreadcrumb } from '../../src';
33

44
// Suppress log output produced by log() calls inside validation failures.
5-
vi.mock('@hawk.so/core', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() }));
5+
vi.mock('../../src/logger/logger', () => ({ log: vi.fn(), isLoggerSet: vi.fn(() => true), setLogger: vi.fn() }));
66

77
describe('validateUser', () => {
88
it('should return false when user is null', () => {

packages/javascript/src/catcher.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import './modules/sanitizer';
12
import Socket from './modules/socket';
23
import type { BreadcrumbsAPI, CatcherMessage, HawkInitialSettings, HawkJavaScriptEvent, Transport } from './types';
34
import { VueIntegration } from './integrations/vue';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Sanitizer } from '@hawk.so/core';
2+
3+
/**
4+
* Registers browser-specific sanitizer handler for {@link Element} objects.
5+
*
6+
* Handles HTML Element and represents as string with it outer HTML with
7+
* inner content replaced: HTMLDivElement -> "<div ...></div>"
8+
*/
9+
Sanitizer.registerHandler({
10+
check: (target) => target instanceof Element,
11+
format: (target: Element) => {
12+
const innerHTML = target.innerHTML;
13+
14+
if (innerHTML) {
15+
return target.outerHTML.replace(target.innerHTML, '…');
16+
}
17+
18+
return target.outerHTML;
19+
},
20+
});

0 commit comments

Comments
 (0)