diff --git a/packages/bruno-app/src/utils/response/detectContentTypeFromBase64.spec.js b/packages/bruno-app/src/utils/response/detectContentTypeFromBase64.spec.js
new file mode 100644
index 00000000000..06e25039290
--- /dev/null
+++ b/packages/bruno-app/src/utils/response/detectContentTypeFromBase64.spec.js
@@ -0,0 +1,85 @@
+import { detectContentTypeFromBase64 } from './index';
+
+const toBase64 = (input) => Buffer.from(input).toString('base64');
+const repeatBytes = (sequence, times = 100) => Array.from({ length: times }, () => sequence).flat();
+
+describe('detectContentTypeFromBase64', () => {
+ describe('text detection', () => {
+ it('detects ASCII JSON as text', () => {
+ expect(detectContentTypeFromBase64(toBase64('{"a":"test"}'))).toBe('text/plain');
+ expect(detectContentTypeFromBase64(toBase64('{\n "a": "test"\n}'))).toBe('text/plain');
+ });
+
+ it('detects UTF-8 JSON with Chinese characters as text', () => {
+ expect(detectContentTypeFromBase64(toBase64('{"a":"测试"}'))).toBe('text/plain');
+ expect(detectContentTypeFromBase64(toBase64('{"code":0,"msg":"操作成功","data":{"name":"张三"}}'))).toBe('text/plain');
+ });
+
+ it('detects UTF-8 JSON with Cyrillic characters as text', () => {
+ expect(detectContentTypeFromBase64(toBase64('{"message":"Привет, мир"}'))).toBe('text/plain');
+ });
+
+ it('detects UTF-8 text with Japanese characters and emoji as text', () => {
+ expect(detectContentTypeFromBase64(toBase64('{"greeting":"こんにちは世界","mood":"😀🎉"}'))).toBe('text/plain');
+ });
+
+ it('detects a UTF-8 body longer than the 512-byte sample as text', () => {
+ const prefix = '{"a":"';
+ const body = prefix + '测'.repeat(300) + '"}';
+ // ASCII prefix followed by 3-byte characters, so the sample is cut inside a character
+ expect(Buffer.byteLength(body)).toBeGreaterThan(512);
+ expect((512 - Buffer.byteLength(prefix)) % 3).not.toBe(0);
+
+ expect(detectContentTypeFromBase64(toBase64(body))).toBe('text/plain');
+ });
+
+ it('detects the first and last code points of the restricted UTF-8 ranges as text', () => {
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xE0, 0xA0, 0x80])))).toBe('text/plain'); // U+0800
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xED, 0x9F, 0xBF])))).toBe('text/plain'); // U+D7FF
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xF0, 0x90, 0x80, 0x80])))).toBe('text/plain'); // U+10000
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xF4, 0x8F, 0xBF, 0xBF])))).toBe('text/plain'); // U+10FFFF
+ });
+
+ it('detects SVG content', () => {
+ expect(detectContentTypeFromBase64(toBase64(''))).toBe('image/svg+xml');
+ expect(detectContentTypeFromBase64(toBase64('\n'))).toBe('image/svg+xml');
+ });
+ });
+
+ describe('binary detection', () => {
+ it('detects binary formats by magic number', () => {
+ expect(detectContentTypeFromBase64(toBase64([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))).toBe('image/png');
+ expect(detectContentTypeFromBase64(toBase64('%PDF-1.7\n'))).toBe('application/pdf');
+ expect(detectContentTypeFromBase64(toBase64([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00]))).toBe('application/gzip');
+ });
+
+ it('returns null for bytes that are not valid UTF-8', () => {
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0x80, 0x81, 0xFE, 0xFF, 0x00])))).toBe(null);
+ });
+
+ it('returns null for UTF-8 lead bytes without continuation bytes', () => {
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xE6, 0x61], 256)))).toBe(null);
+ });
+
+ it('returns null for overlong, surrogate and out-of-range UTF-8 sequences', () => {
+ // Overlong 3- and 4-byte encodings
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xE0, 0x80, 0x80])))).toBe(null);
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xF0, 0x80, 0x80, 0x80])))).toBe(null);
+ // A UTF-16 surrogate encoded as UTF-8
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xED, 0xA0, 0x80])))).toBe(null);
+ // A code point above U+10FFFF
+ expect(detectContentTypeFromBase64(toBase64(repeatBytes([0xF4, 0x90, 0x80, 0x80])))).toBe(null);
+ });
+
+ it('returns null for mostly binary data that contains a few UTF-8 characters', () => {
+ const buffer = Buffer.concat([Buffer.from('测试'), Buffer.alloc(200)]);
+ expect(detectContentTypeFromBase64(buffer.toString('base64'))).toBe(null);
+ });
+ });
+
+ it('returns null for empty or missing input', () => {
+ expect(detectContentTypeFromBase64('')).toBe(null);
+ expect(detectContentTypeFromBase64(undefined)).toBe(null);
+ expect(detectContentTypeFromBase64(null)).toBe(null);
+ });
+});
diff --git a/packages/bruno-app/src/utils/response/index.js b/packages/bruno-app/src/utils/response/index.js
index af980bd4cea..d3f9a0f9f06 100644
--- a/packages/bruno-app/src/utils/response/index.js
+++ b/packages/bruno-app/src/utils/response/index.js
@@ -69,6 +69,41 @@ export const escapeHtml = (text) => {
.replace(/'/g, ''');
};
+/**
+ * Returns the byte length of the UTF-8 multi-byte character starting at `index`,
+ * or 0 if the bytes there are not a complete, well-formed multi-byte sequence before `end`
+ */
+const getUtf8SequenceLength = (buffer, index, end) => {
+ const byte = buffer[index];
+ let length;
+ if (byte >= 0xC2 && byte <= 0xDF) {
+ length = 2;
+ } else if (byte >= 0xE0 && byte <= 0xEF) {
+ length = 3;
+ } else if (byte >= 0xF0 && byte <= 0xF4) {
+ length = 4;
+ } else {
+ return 0;
+ }
+
+ if (index + length > end) return 0;
+
+ // Reject overlong encodings, UTF-16 surrogates and code points above U+10FFFF
+ const secondByte = buffer[index + 1];
+ if ((byte === 0xE0 && secondByte < 0xA0)
+ || (byte === 0xED && secondByte > 0x9F)
+ || (byte === 0xF0 && secondByte < 0x90)
+ || (byte === 0xF4 && secondByte > 0x8F)) {
+ return 0;
+ }
+
+ for (let j = 1; j < length; j++) {
+ // Continuation bytes are 10xxxxxx
+ if ((buffer[index + j] & 0xC0) !== 0x80) return 0;
+ }
+ return length;
+};
+
/**
* Helper to detect if buffer contains text data
*/
@@ -85,6 +120,14 @@ const isLikelyText = (buffer) => {
|| byte === 0x0A // Line feed
|| byte === 0x0D) { // Carriage return
textChars++;
+ continue;
+ }
+
+ // Non-ASCII characters (e.g. Chinese, Cyrillic, emoji) encoded as UTF-8 are text too
+ const sequenceLength = getUtf8SequenceLength(buffer, i, sampleSize);
+ if (sequenceLength > 0) {
+ textChars += sequenceLength;
+ i += sequenceLength - 1;
}
}