docs: design for React error boundaries (#62) #83
@@ -14,13 +14,44 @@ const MAX_STACK = 4000;
|
|||||||
const MAX_COMPONENT_STACK = 4000;
|
const MAX_COMPONENT_STACK = 4000;
|
||||||
const MAX_PATH = 200;
|
const MAX_PATH = 200;
|
||||||
|
|
||||||
|
// True for CR, LF and every other C0 control character, plus DEL (the
|
||||||
|
// C0 range is code points 0 through 31; DEL is 127). Written as a numeric
|
||||||
|
// comparison rather than a control-character regex literal so the source
|
||||||
|
// never has to embed a raw control character or an escape sequence for one.
|
||||||
|
const LAST_C0_CODE = 31;
|
||||||
|
const DEL_CODE = 127;
|
||||||
|
function isControlCharCode(code: number): boolean {
|
||||||
|
return code <= LAST_C0_CODE || code === DEL_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strips CR, LF and other control characters from a string, replacing each
|
||||||
|
// with a single space. The endpoint is unauthenticated, so without this a
|
||||||
|
// caller could embed a newline in any field to forge what looks like a
|
||||||
|
// second [client-error] line in the shared server log. The replacement is
|
||||||
|
// 1-for-1 (one control character becomes one space), so it cannot change
|
||||||
|
// the string's length either way.
|
||||||
|
function sanitize(value: string): string {
|
||||||
|
let result = '';
|
||||||
|
for (const char of value) {
|
||||||
|
result += isControlCharCode(char.codePointAt(0) ?? 0) ? ' ' : char;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// Anything that is not a string becomes empty rather than 'undefined' or
|
// Anything that is not a string becomes empty rather than 'undefined' or
|
||||||
// '[object Object]', so a malformed field cannot dress itself up as content.
|
// '[object Object]', so a malformed field cannot dress itself up as content.
|
||||||
function clip(value: unknown, max: number): string {
|
function clip(value: unknown, max: number): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return value.length > max ? `${value.slice(0, max)}… [truncated]` : value;
|
// Sanitize before truncating, not after. Because the substitution above is
|
||||||
|
// 1-for-1, sanitizing first cannot push the stored length past `max` — an
|
||||||
|
// escaping scheme that expanded a control character into multiple visible
|
||||||
|
// characters would need the opposite order to keep that same guarantee, so
|
||||||
|
// the two are not interchangeable and must not be reordered without
|
||||||
|
// re-checking this.
|
||||||
|
const sanitized = sanitize(value);
|
||||||
|
return sanitized.length > max ? `${sanitized.slice(0, max)}… [truncated]` : sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No asyncRoute: this handler is synchronous, so there is no promise for the
|
// No asyncRoute: this handler is synchronous, so there is no promise for the
|
||||||
|
|||||||
@@ -68,4 +68,86 @@ describe('POST /api/client-errors', () => {
|
|||||||
expect(logged).toContain('[truncated]');
|
expect(logged).toContain('[truncated]');
|
||||||
expect(logged.length).toBeLessThan(2000);
|
expect(logged.length).toBeLessThan(2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The boundary itself, not just "way over the limit": a message of exactly
|
||||||
|
// MAX_MESSAGE (500) must survive untouched.
|
||||||
|
it('leaves a message of exactly the length limit unmodified', async () => {
|
||||||
|
const message = 'a'.repeat(500);
|
||||||
|
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
expect(logged).toContain(`message: ${message}\n`);
|
||||||
|
expect(logged).not.toContain('[truncated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
// One character past the boundary must truncate.
|
||||||
|
it('truncates a message one character past the length limit', async () => {
|
||||||
|
const message = 'a'.repeat(501);
|
||||||
|
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates an oversized stack independently of message', async () => {
|
||||||
|
const res = await request(app).post('/api/client-errors').send({
|
||||||
|
context: 'page',
|
||||||
|
message: 'short',
|
||||||
|
stack: 'x'.repeat(4001)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
|
||||||
|
expect(stackLine).toContain('[truncated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates an oversized componentStack independently of message', async () => {
|
||||||
|
const res = await request(app).post('/api/client-errors').send({
|
||||||
|
context: 'page',
|
||||||
|
message: 'short',
|
||||||
|
componentStack: 'x'.repeat(4001)
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
const componentStackLine = logged
|
||||||
|
.split('\n')
|
||||||
|
.find((line) => line.trim().startsWith('componentStack:'));
|
||||||
|
expect(componentStackLine).toContain('[truncated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates an oversized path independently of message', async () => {
|
||||||
|
const res = await request(app).post('/api/client-errors').send({
|
||||||
|
context: 'page',
|
||||||
|
message: 'short',
|
||||||
|
path: '/'.concat('x'.repeat(201))
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
const pathLine = logged.split('\n')[0];
|
||||||
|
expect(pathLine).toContain('[truncated]');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The endpoint is unauthenticated, so nothing stops a caller from sending a
|
||||||
|
// message crafted to look like a second [client-error] line. This is what
|
||||||
|
// Finding 1 closes: an embedded CR/LF must not survive into the log.
|
||||||
|
it('collapses embedded newlines so a report cannot forge a second log line', async () => {
|
||||||
|
const res = await request(app).post('/api/client-errors').send({
|
||||||
|
context: 'modal',
|
||||||
|
message: 'real error\n[client-error] context=page path=/fake\r\n message: forged entry'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
const logged = errorSpy.mock.calls[0][0] as string;
|
||||||
|
|
||||||
|
// The template itself joins four fixed lines with three newlines; that
|
||||||
|
// count must not grow no matter what the caller sends.
|
||||||
|
expect(logged.split('\n')).toHaveLength(4);
|
||||||
|
expect(logged).not.toContain('\n[client-error]');
|
||||||
|
expect(logged).not.toContain('\r');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user