fix(logs): rebuild legacy HTML safely
This commit is contained in:
@@ -18,3 +18,4 @@ export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './ranking/legacyColor.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
export * from './logging/formatLegacyLogHtml.js';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const legacyTagPattern = /^<([RBGMCLSODYW]1?|1|\/)>$/;
|
||||
|
||||
const legacyStyleMap: Record<string, string> = {
|
||||
R: 'color: red;',
|
||||
B: 'color: blue;',
|
||||
G: 'color: green;',
|
||||
M: 'color: magenta;',
|
||||
C: 'color: cyan;',
|
||||
L: 'color: limegreen;',
|
||||
S: 'color: skyblue;',
|
||||
O: 'color: orangered;',
|
||||
D: 'color: orangered;',
|
||||
Y: 'color: yellow;',
|
||||
W: 'color: white;',
|
||||
1: 'font-size: 0.9em;',
|
||||
};
|
||||
|
||||
const safeSpanClasses = new Set([
|
||||
'ev_highlight',
|
||||
'ev_failed',
|
||||
'ev_notice',
|
||||
'me',
|
||||
'you',
|
||||
'name_plate',
|
||||
'crew_type',
|
||||
'name_plate_cover',
|
||||
'crew_plate',
|
||||
'remain_crew',
|
||||
'killed_plate',
|
||||
'killed_crew',
|
||||
'name',
|
||||
'war_type',
|
||||
'war_type_attack',
|
||||
'war_type_defense',
|
||||
'war_type_siege',
|
||||
]);
|
||||
|
||||
const escapeText = (value: string): string =>
|
||||
value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
|
||||
const normalizeStructuralTag = (tag: string): string | null => {
|
||||
if (/^<b\s*>$/i.test(tag)) return '<b>';
|
||||
if (/^<\/b\s*>$/i.test(tag)) return '</b>';
|
||||
if (/^<br\s*\/?\s*>$/i.test(tag)) return '<br>';
|
||||
if (/^<\/span\s*>$/i.test(tag)) return '</span>';
|
||||
if (/^<\/div\s*>$/i.test(tag)) return '</div>';
|
||||
|
||||
const colorSpan = tag.match(/^<span\s+style\s*=\s*(['"])\s*color\s*:\s*(#[0-9a-f]{6})\s*;?\s*\1\s*>$/i);
|
||||
if (colorSpan) return `<span style="color: ${colorSpan[2]};">`;
|
||||
|
||||
const open = tag.match(/^<(span|div)\s+class\s*=\s*(['"])([^'"]+)\2\s*>$/i);
|
||||
if (!open) return null;
|
||||
|
||||
const element = open[1]!.toLowerCase();
|
||||
const classes = open[3]!.trim().split(/\s+/u);
|
||||
if (element === 'div') {
|
||||
return classes.length === 1 && classes[0] === 'small_war_log' ? '<div class="small_war_log">' : null;
|
||||
}
|
||||
if (classes.length === 0 || classes.some((className) => !safeSpanClasses.has(className))) {
|
||||
return null;
|
||||
}
|
||||
return `<span class="${classes.join(' ')}">`;
|
||||
};
|
||||
|
||||
export type FormatLegacyLogHtmlOptions = {
|
||||
colorize?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Ref's custom color markers while rebuilding only the small set of
|
||||
* HTML structures emitted by legacy log writers. Everything else is text.
|
||||
*/
|
||||
export const formatLegacyLogHtml = (value?: string | null, options: FormatLegacyLogHtmlOptions = {}): string => {
|
||||
if (!value) return '';
|
||||
|
||||
const colorize = options.colorize ?? true;
|
||||
return value
|
||||
.split(/(<[^>]*>)/gu)
|
||||
.map((part) => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return escapeText(part);
|
||||
}
|
||||
|
||||
const legacy = part.match(legacyTagPattern)?.[1];
|
||||
if (legacy) {
|
||||
if (!colorize) return '';
|
||||
if (legacy === '/') return '</span>';
|
||||
const colorCode = legacy === '1' ? null : legacy[0]!;
|
||||
const small = legacy === '1' || legacy.endsWith('1');
|
||||
return `<span style="${colorCode ? (legacyStyleMap[colorCode] ?? '') : ''}${small ? legacyStyleMap['1'] : ''}">`;
|
||||
}
|
||||
|
||||
return normalizeStructuralTag(part) ?? escapeText(part);
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatLegacyLogHtml } from '../src/logging/formatLegacyLogHtml.js';
|
||||
|
||||
describe('formatLegacyLogHtml', () => {
|
||||
it('converts legacy colors and preserves intentional emphasis and line breaks', () => {
|
||||
expect(formatLegacyLogHtml('<R><b>위험</b></><br><Y1>작게</><1>작게만</>')).toBe(
|
||||
'<span style="color: red;"><b>위험</b></span><br><span style="color: yellow;font-size: 0.9em;">작게</span><span style="font-size: 0.9em;">작게만</span>'
|
||||
);
|
||||
expect(formatLegacyLogHtml('<R>색상</>', { colorize: false })).toBe('색상');
|
||||
});
|
||||
|
||||
it('escapes executable and unknown markup instead of passing it to v-html', () => {
|
||||
const dirty = [
|
||||
'<script>globalThis.__logXss=1</script>',
|
||||
'<img src=x onerror="globalThis.__logXss=2">',
|
||||
'<svg><a href="javascript:alert(1)">SVG</a></svg>',
|
||||
'<span class="name" onclick="alert(2)">이름</span>',
|
||||
'<a href="javascript:alert(3)">링크</a>',
|
||||
].join('');
|
||||
const clean = formatLegacyLogHtml(dirty);
|
||||
|
||||
expect(clean).toContain('<script>globalThis.__logXss=1</script>');
|
||||
expect(clean).toContain('<img src=x onerror="globalThis.__logXss=2">');
|
||||
expect(clean).toContain('<span class="name" onclick="alert(2)">이름</span>');
|
||||
expect(clean).not.toMatch(/<script|<img|<svg|<a /i);
|
||||
});
|
||||
|
||||
it('rebuilds only the classed markup emitted by battle and tournament logs', () => {
|
||||
const source =
|
||||
'<div class="small_war_log"><span class="me"><span class="name">장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
"<span class='ev_failed'>실패</span><span class='ev_notice'>주의</span></div>";
|
||||
expect(formatLegacyLogHtml(source)).toBe(
|
||||
'<div class="small_war_log"><span class="me"><span class="name">장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
'<span class="ev_failed">실패</span><span class="ev_notice">주의</span></div>'
|
||||
);
|
||||
expect(formatLegacyLogHtml('<span class="unknown">미허용</span>')).toBe(
|
||||
'<span class="unknown">미허용</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fixed hex color form emitted by flag-change logs but rejects other inline CSS', () => {
|
||||
expect(formatLegacyLogHtml("<span style='color:#FF6347;'><b>국기</b></span>")).toBe(
|
||||
'<span style="color: #FF6347;"><b>국기</b></span>'
|
||||
);
|
||||
expect(formatLegacyLogHtml("<span style='color:red;background:url(javascript:x)'>오염</span>")).toBe(
|
||||
"<span style='color:red;background:url(javascript:x)'>오염</span>"
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes dynamic text and incomplete tags', () => {
|
||||
expect(formatLegacyLogHtml('A&B <img src=x')).toBe('A&B <img src=x');
|
||||
expect(formatLegacyLogHtml('<script>')).toBe('&#60;script&#62;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user