fix(Query): Parse html string error responses to avoid displaying raw HTML as error message (#29321)

This commit is contained in:
Ross Mabbett 2024-06-25 20:48:35 -04:00 committed by GitHub
parent b5a72e21f7
commit de6a518161
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 345 additions and 30 deletions

View File

@ -23,10 +23,12 @@ import {
t,
SupersetError,
ErrorTypeEnum,
isProbablyHTML,
isJsonString,
} from '@superset-ui/core';
// The response always contains an error attribute, can contain anything from the
// SupersetClientResponse object, and can contain a spread JSON blob
// The response always contains an error attribute, can contain anything from
// the SupersetClientResponse object, and can contain a spread JSON blob
export type ClientErrorObject = {
error: string;
errors?: SupersetError[];
@ -51,8 +53,74 @@ type ErrorType =
type ErrorTextSource = 'dashboard' | 'chart' | 'query' | 'dataset' | 'database';
export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
let error = { ...responseObject };
const ERROR_CODE_LOOKUP = {
400: 'Bad request',
401: 'Unauthorized',
402: 'Payment required',
403: 'Forbidden',
404: 'Not found',
405: 'Method not allowed',
406: 'Not acceptable',
407: 'Proxy authentication required',
408: 'Request timeout',
409: 'Conflict',
410: 'Gone',
411: 'Length required',
412: 'Precondition failed',
413: 'Payload too large',
414: 'URI too long',
415: 'Unsupported media type',
416: 'Range not satisfiable',
417: 'Expectation failed',
418: "I'm a teapot",
500: 'Server error',
501: 'Not implemented',
502: 'Bad gateway',
503: 'Service unavailable',
504: 'Gateway timeout',
505: 'HTTP version not supported',
506: 'Variant also negotiates',
507: 'Insufficient storage',
508: 'Loop detected',
510: 'Not extended',
511: 'Network authentication required',
599: 'Network error',
};
export function checkForHtml(str: string): boolean {
return !isJsonString(str) && isProbablyHTML(str);
}
export function parseStringResponse(str: string): string {
if (checkForHtml(str)) {
for (const [code, message] of Object.entries(ERROR_CODE_LOOKUP)) {
const regex = new RegExp(`${code}|${message}`, 'i');
if (regex.test(str)) {
return t(message);
}
}
return t('Unknown error');
}
return str;
}
export function getErrorFromStatusCode(status: number): string | null {
return ERROR_CODE_LOOKUP[status] || null;
}
export function retrieveErrorMessage(
str: string,
errorObject: JsonObject,
): string {
const statusError =
'status' in errorObject ? getErrorFromStatusCode(errorObject.status) : null;
// Prefer status code message over the response or HTML text
return statusError || parseStringResponse(str);
}
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
let error = { ...responseJson };
// Backwards compatibility for old error renderers with the new error object
if (error.errors && error.errors.length > 0) {
error.error = error.description = error.errors[0].message;
@ -67,7 +135,11 @@ export function parseErrorJson(responseObject: JsonObject): ClientErrorObject {
t('Invalid input');
}
if (typeof error.message === 'string') {
error.error = error.message;
if (checkForHtml(error.message)) {
error.error = retrieveErrorMessage(error.message, error);
} else {
error.error = error.message;
}
}
}
if (error.stack) {
@ -95,11 +167,12 @@ export function getClientErrorObject(
| { response: Response }
| string,
): Promise<ClientErrorObject> {
// takes a SupersetClientResponse as input, attempts to read response as Json if possible,
// and returns a Promise that resolves to a plain object with error key and text value.
// takes a SupersetClientResponse as input, attempts to read response as Json
// if possible, and returns a Promise that resolves to a plain object with
// error key and text value.
return new Promise(resolve => {
if (typeof response === 'string') {
resolve({ error: response });
resolve({ error: parseStringResponse(response) });
return;
}
@ -149,20 +222,32 @@ export function getClientErrorObject(
const responseObject =
response instanceof Response ? response : response.response;
if (responseObject && !responseObject.bodyUsed) {
// attempt to read the body as json, and fallback to text. we must clone the
// response in order to fallback to .text() because Response is single-read
// attempt to read the body as json, and fallback to text. we must clone
// the response in order to fallback to .text() because Response is
// single-read
responseObject
.clone()
.json()
.then(errorJson => {
const error = { ...responseObject, ...errorJson };
// Destructuring instead of spreading to avoid loss of sibling properties to the body
const { url, status, statusText, redirected, type } = responseObject;
const responseSummary = { url, status, statusText, redirected, type };
const error = {
...errorJson,
...responseSummary,
};
resolve(parseErrorJson(error));
})
.catch(() => {
// fall back to reading as text
responseObject.text().then((errorText: any) => {
resolve({ ...responseObject, error: errorText });
resolve({
// Destructuring not necessary here
...responseObject,
error: retrieveErrorMessage(errorText, responseObject),
});
});
});
return;
@ -177,7 +262,7 @@ export function getClientErrorObject(
}
resolve({
...responseObject,
error,
error: parseStringResponse(error),
});
});
}

View File

@ -22,6 +22,8 @@ import {
sanitizeHtmlIfNeeded,
safeHtmlSpan,
removeHTMLTags,
isJsonString,
getParagraphContents,
} from './html';
describe('sanitizeHtml', () => {
@ -113,3 +115,77 @@ describe('removeHTMLTags', () => {
expect(output).toBe('Unclosed tag');
});
});
describe('isJsonString', () => {
test('valid JSON object', () => {
const jsonString = '{"name": "John", "age": 30, "city": "New York"}';
expect(isJsonString(jsonString)).toBe(true);
});
test('valid JSON array', () => {
const jsonString = '[1, 2, 3, 4, 5]';
expect(isJsonString(jsonString)).toBe(true);
});
test('valid JSON string', () => {
const jsonString = '"Hello, world!"';
expect(isJsonString(jsonString)).toBe(true);
});
test('invalid JSON with syntax error', () => {
const jsonString = '{"name": "John", "age": 30, "city": "New York"';
expect(isJsonString(jsonString)).toBe(false);
});
test('empty string', () => {
const jsonString = '';
expect(isJsonString(jsonString)).toBe(false);
});
test('non-JSON string', () => {
const jsonString = '<p>Hello, <strong>World!</strong></p>';
expect(isJsonString(jsonString)).toBe(false);
});
test('non-JSON formatted number', () => {
const jsonString = '12345abc';
expect(isJsonString(jsonString)).toBe(false);
});
});
describe('getParagraphContents', () => {
test('should return an object with keys for each paragraph tag', () => {
const htmlString =
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>';
const result = getParagraphContents(htmlString);
expect(result).toEqual({
p1: 'First paragraph.',
p2: 'Second paragraph.',
});
});
test('should return null if the string is not HTML', () => {
const nonHtmlString = 'Just a plain text string.';
expect(getParagraphContents(nonHtmlString)).toBeNull();
});
test('should return null if there are no <p> tags in the HTML string', () => {
const htmlStringWithoutP = '<div><span>No paragraph here.</span></div>';
expect(getParagraphContents(htmlStringWithoutP)).toBeNull();
});
test('should return an object with empty string for empty <p> tag', () => {
const htmlStringWithEmptyP = '<div><p></p></div>';
const result = getParagraphContents(htmlStringWithEmptyP);
expect(result).toEqual({ p1: '' });
});
test('should handle HTML strings with nested <p> tags correctly', () => {
const htmlStringWithNestedP =
'<div><p>First paragraph <span>with nested</span> content.</p></div>';
const result = getParagraphContents(htmlStringWithNestedP);
expect(result).toEqual({
p1: 'First paragraph with nested content.',
});
});
});

View File

@ -44,10 +44,26 @@ export function sanitizeHtml(htmlString: string) {
return xssFilter.process(htmlString);
}
export function hasHtmlTagPattern(str: string): boolean {
const htmlTagPattern =
/<(html|head|body|div|span|a|p|h[1-6]|title|meta|link|script|style)/i;
return htmlTagPattern.test(str);
}
export function isProbablyHTML(text: string) {
return Array.from(
new DOMParser().parseFromString(text, 'text/html').body.childNodes,
).some(({ nodeType }) => nodeType === 1);
const cleanedStr = text.trim().toLowerCase();
if (
cleanedStr.startsWith('<!doctype html>') &&
hasHtmlTagPattern(cleanedStr)
) {
return true;
}
const parser = new DOMParser();
const doc = parser.parseFromString(cleanedStr, 'text/html');
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
}
export function sanitizeHtmlIfNeeded(htmlString: string) {
@ -70,3 +86,36 @@ export function safeHtmlSpan(possiblyHtmlString: string) {
export function removeHTMLTags(str: string): string {
return str.replace(/<[^>]*>/g, '');
}
export function isJsonString(str: string): boolean {
try {
JSON.parse(str);
return true;
} catch (e) {
return false;
}
}
export function getParagraphContents(
str: string,
): { [key: string]: string } | null {
if (!isProbablyHTML(str)) {
return null;
}
const parser = new DOMParser();
const doc = parser.parseFromString(str, 'text/html');
const pTags = doc.querySelectorAll('p');
if (pTags.length === 0) {
return null;
}
const paragraphContents: { [key: string]: string } = {};
pTags.forEach((pTag, index) => {
paragraphContents[`p${index + 1}`] = pTag.textContent || '';
});
return paragraphContents;
}

View File

@ -25,19 +25,59 @@ import {
ErrorTypeEnum,
} from '@superset-ui/core';
it('Returns a Promise', () => {
test('Returns a Promise', () => {
const response = getClientErrorObject('error');
expect(response instanceof Promise).toBe(true);
});
it('Returns a Promise that resolves to an object with an error key', async () => {
test('Returns a Promise that resolves to an object with an error key', async () => {
const error = 'error';
const errorObj = await getClientErrorObject(error);
expect(errorObj).toMatchObject({ error });
});
it('Handles Response that can be parsed as json', async () => {
test('should handle HTML response with "500" or "server error"', async () => {
const htmlString500 = '<div>500: Internal Server Error</div>';
const clientErrorObject500 = await getClientErrorObject(htmlString500);
expect(clientErrorObject500).toEqual({ error: 'Server error' });
const htmlStringServerError = '<div>Server error message</div>';
const clientErrorObjectServerError = await getClientErrorObject(
htmlStringServerError,
);
expect(clientErrorObjectServerError).toEqual({
error: 'Server error',
});
});
test('should handle HTML response with "404" or "not found"', async () => {
const htmlString404 = '<div>404: Page not found</div>';
const clientErrorObject404 = await getClientErrorObject(htmlString404);
expect(clientErrorObject404).toEqual({ error: 'Not found' });
const htmlStringNotFoundError = '<div>Not found message</div>';
const clientErrorObjectNotFoundError = await getClientErrorObject(
htmlStringNotFoundError,
);
expect(clientErrorObjectNotFoundError).toEqual({
error: 'Not found',
});
});
test('should handle HTML response without common error code', async () => {
const htmlString = '<!doctype html><div>Foo bar Lorem Ipsum</div>';
const clientErrorObject = await getClientErrorObject(htmlString);
expect(clientErrorObject).toEqual({ error: 'Unknown error' });
const htmlString2 = '<div><p>An error occurred</p></div>';
const clientErrorObject2 = await getClientErrorObject(htmlString2);
expect(clientErrorObject2).toEqual({
error: 'Unknown error',
});
});
test('Handles Response that can be parsed as json', async () => {
const jsonError = { something: 'something', error: 'Error message' };
const jsonErrorString = JSON.stringify(jsonError);
@ -45,7 +85,7 @@ it('Handles Response that can be parsed as json', async () => {
expect(errorObj).toMatchObject(jsonError);
});
it('Handles backwards compatibility between old error messages and the new SIP-40 errors format', async () => {
test('Handles backwards compatibility between old error messages and the new SIP-40 errors format', async () => {
const jsonError = {
errors: [
{
@ -63,14 +103,21 @@ it('Handles backwards compatibility between old error messages and the new SIP-4
expect(errorObj.link).toEqual(jsonError.errors[0].extra.link);
});
it('Handles Response that can be parsed as text', async () => {
test('Handles Response that can be parsed as text', async () => {
const textError = 'Hello I am a text error';
const errorObj = await getClientErrorObject(new Response(textError));
expect(errorObj).toMatchObject({ error: textError });
});
it('Handles TypeError Response', async () => {
test('Handles Response that contains raw html be parsed as text', async () => {
const textError = 'Hello I am a text error';
const errorObj = await getClientErrorObject(new Response(textError));
expect(errorObj).toMatchObject({ error: textError });
});
test('Handles TypeError Response', async () => {
const error = new TypeError('Failed to fetch');
// @ts-ignore
@ -78,7 +125,7 @@ it('Handles TypeError Response', async () => {
expect(errorObj).toMatchObject({ error: 'Network error' });
});
it('Handles timeout error', async () => {
test('Handles timeout error', async () => {
const errorObj = await getClientErrorObject({
timeout: 1000,
statusText: 'timeout',
@ -110,14 +157,30 @@ it('Handles timeout error', async () => {
});
});
it('Handles plain text as input', async () => {
test('Handles plain text as input', async () => {
const error = 'error';
const errorObj = await getClientErrorObject(error);
expect(errorObj).toMatchObject({ error });
});
it('Handles error with status text and message', async () => {
test('Handles error with status code', async () => {
const status500 = new Response(null, { status: 500 });
const status404 = new Response(null, { status: 404 });
const status502 = new Response(null, { status: 502 });
expect(await getClientErrorObject(status500)).toMatchObject({
error: 'Server error',
});
expect(await getClientErrorObject(status404)).toMatchObject({
error: 'Not found',
});
expect(await getClientErrorObject(status502)).toMatchObject({
error: 'Bad gateway',
});
});
test('Handles error with status text and message', async () => {
const statusText = 'status';
const message = 'message';
@ -135,7 +198,7 @@ it('Handles error with status text and message', async () => {
});
});
it('getClientErrorMessage', () => {
test('getClientErrorMessage', () => {
expect(getClientErrorMessage('error')).toEqual('error');
expect(
getClientErrorMessage('error', {
@ -150,7 +213,7 @@ it('getClientErrorMessage', () => {
).toEqual('error:\nclient error');
});
it('parseErrorJson with message', () => {
test('parseErrorJson with message', () => {
expect(parseErrorJson({ message: 'error message' })).toEqual({
message: 'error message',
error: 'error message',
@ -181,7 +244,49 @@ it('parseErrorJson with message', () => {
});
});
it('parseErrorJson with stacktrace', () => {
test('parseErrorJson with HTML message', () => {
expect(
parseErrorJson({
message: '<div>error message</div>',
}),
).toEqual({
message: '<div>error message</div>',
error: 'Unknown error',
});
expect(
parseErrorJson({
message: '<div>Server error</div>',
}),
).toEqual({
message: '<div>Server error</div>',
error: 'Server error',
});
});
test('parseErrorJson with HTML message and status code', () => {
expect(
parseErrorJson({
status: 502,
message: '<div>error message</div>',
}),
).toEqual({
status: 502,
message: '<div>error message</div>',
error: 'Bad gateway',
});
expect(
parseErrorJson({
status: 999,
message: '<div>Server error</div>',
}),
).toEqual({
status: 999,
message: '<div>Server error</div>',
error: 'Server error',
});
});
test('parseErrorJson with stacktrace', () => {
expect(
parseErrorJson({ error: 'error message', stack: 'stacktrace' }),
).toEqual({
@ -204,7 +309,7 @@ it('parseErrorJson with stacktrace', () => {
});
});
it('parseErrorJson with CSRF', () => {
test('parseErrorJson with CSRF', () => {
expect(
parseErrorJson({
responseText: 'CSRF',
@ -215,7 +320,7 @@ it('parseErrorJson with CSRF', () => {
});
});
it('getErrorText', async () => {
test('getErrorText', async () => {
expect(await getErrorText('error', 'dashboard')).toEqual(
'Sorry, there was an error saving this dashboard: error',
);