Skip to content

Commit 4c64a29

Browse files
🔒️ Render cloud logs via DOM instead of HTML strings
1 parent 186dd9e commit 4c64a29

4 files changed

Lines changed: 90 additions & 65 deletions

File tree

src/cloud/commands/logs.ts

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,6 @@ const FILTER_CHIPS = [
3636
{ level: "critical", label: "CRITICAL" },
3737
]
3838

39-
const HTML_ESCAPE: Record<string, string> = {
40-
"&": "&amp;",
41-
"<": "&lt;",
42-
">": "&gt;",
43-
'"': "&quot;",
44-
}
45-
46-
function escapeHtml(text: string): string {
47-
return text.replace(/[&<>"]/g, (ch) => HTML_ESCAPE[ch])
48-
}
49-
5039
function formatTimestamp(ts: string): string {
5140
const d = new Date(ts)
5241
return Number.isNaN(d.getTime()) ? ts : `${d.toISOString().slice(0, 23)}Z`
@@ -71,14 +60,20 @@ function normalizeLevel(level: string, message?: string): string {
7160
return resolved
7261
}
7362

74-
export function formatLogEntry(entry: AppLogEntry): string {
63+
export interface FormattedLogEntry {
64+
level: string
65+
timestamp: string
66+
message: string
67+
}
68+
69+
export function formatLogEntry(entry: AppLogEntry): FormattedLogEntry {
7570
const rawLevel = (entry.level ?? "info").toLowerCase()
7671
const level = normalizeLevel(rawLevel, entry.message)
77-
const pipeColor = LEVEL_COLORS[level] ?? LEVEL_COLORS.default
78-
const ts = escapeHtml(formatTimestamp(entry.timestamp))
79-
const msg = escapeHtml(entry.message)
80-
const escapedLevel = escapeHtml(level)
81-
return `<div class="log-line" data-level="${escapedLevel}"><span class="pipe" style="color:${pipeColor}">┃</span> <span class="ts">${ts}</span> ${msg}</div>`
72+
return {
73+
level,
74+
timestamp: formatTimestamp(entry.timestamp),
75+
message: entry.message,
76+
}
8277
}
8378

8479
// --- Webview HTML ---
@@ -290,7 +285,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider {
290285
count++
291286
this.view.webview.postMessage({
292287
type: "log",
293-
html: formatLogEntry(entry),
288+
entry: formatLogEntry(entry),
294289
})
295290
}
296291
clearTimeout(connectedTimer)

src/cloud/ui/panel/styles.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,30 @@ body {
248248
display: none;
249249
}
250250

251+
.pipe {
252+
color: #888;
253+
}
254+
255+
.log-line[data-level="debug"] .pipe {
256+
color: #4488ff;
257+
}
258+
259+
.log-line[data-level="info"] .pipe {
260+
color: #00cccc;
261+
}
262+
263+
.log-line[data-level="warning"] .pipe {
264+
color: #ccaa00;
265+
}
266+
267+
.log-line[data-level="error"] .pipe {
268+
color: #f14c4c;
269+
}
270+
271+
.log-line[data-level="critical"] .pipe {
272+
color: #cc66cc;
273+
}
274+
251275
.ts {
252276
opacity: 0.5;
253277
}

src/cloud/ui/panel/webview.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,28 @@ function setStreamingState(streaming: boolean, appLabel?: string): void {
129129
streaming && appLabel ? `Streaming logs for ${appLabel}...` : ""
130130
}
131131

132+
interface LogEntry {
133+
level: string
134+
timestamp: string
135+
message: string
136+
}
137+
138+
// Build the log line as a DOM node. The untrusted message is set as a text
139+
// node, so it is never parsed as HTML — no sanitization needed.
140+
function buildLogLine(entry: LogEntry): HTMLElement {
141+
const line = document.createElement("div")
142+
line.className = "log-line"
143+
line.dataset.level = entry.level
144+
const pipe = document.createElement("span")
145+
pipe.className = "pipe"
146+
pipe.textContent = "┃"
147+
const ts = document.createElement("span")
148+
ts.className = "ts"
149+
ts.textContent = entry.timestamp
150+
line.append(pipe, " ", ts, " ", entry.message)
151+
return line
152+
}
153+
132154
window.addEventListener("message", (event) => {
133155
const msg = event.data
134156
if (msg.type === "log") {
@@ -137,13 +159,12 @@ window.addEventListener("message", (event) => {
137159
firstEntry = false
138160
}
139161
const wasAtBottom = isNearBottom()
140-
logs.insertAdjacentHTML("beforeend", msg.html)
141-
const last = logs.lastElementChild as HTMLElement | null
162+
const line = buildLogLine(msg.entry)
163+
logs.append(line)
142164
if (
143-
last &&
144-
!shouldShow(last, getSelectedLevels(), searchInput.value.toLowerCase())
165+
!shouldShow(line, getSelectedLevels(), searchInput.value.toLowerCase())
145166
) {
146-
last.classList.add("filtered")
167+
line.classList.add("filtered")
147168
}
148169
if (wasAtBottom) window.scrollTo(0, document.body.scrollHeight)
149170
} else if (msg.type === "status") {

src/test/cloud/commands/logs.test.ts

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ suite("cloud/commands/logs", () => {
136136

137137
const logMessages = messages.filter((m) => m.type === "log")
138138
assert.strictEqual(logMessages.length, 2)
139-
assert.ok(logMessages[0].html.includes("line 1"))
140-
assert.ok(logMessages[1].html.includes("line 2"))
139+
assert.strictEqual(logMessages[0].entry.message, "line 1")
140+
assert.strictEqual(logMessages[0].entry.level, "info")
141+
assert.strictEqual(logMessages[1].entry.message, "line 2")
142+
assert.strictEqual(logMessages[1].entry.level, "error")
141143

142144
const statusMessages = messages.filter((m) => m.type === "status")
143145
assert.ok(statusMessages.some((m) => m.text === "Stream ended."))
@@ -446,104 +448,87 @@ suite("cloud/commands/logs", () => {
446448
})
447449

448450
suite("formatLogEntry", () => {
449-
test("formats a log entry with level, timestamp, and message", () => {
450-
const html = formatLogEntry({
451+
test("returns level, timestamp, and message fields", () => {
452+
const entry = formatLogEntry({
451453
timestamp: "2025-01-15T10:30:00Z",
452454
message: "Server started",
453455
level: "info",
454456
})
455-
assert.ok(html.startsWith('<div class="log-line"'))
456-
assert.ok(html.includes('data-level="info"'))
457-
assert.ok(html.includes("color:#00cccc"))
458-
assert.ok(html.includes("Server started"))
459-
assert.ok(html.includes("2025-01-15T10:30:00.000Z"))
460-
assert.ok(html.includes('<span class="ts">'))
461-
assert.ok(html.includes("┃"))
457+
assert.equal(entry.level, "info")
458+
assert.equal(entry.message, "Server started")
459+
assert.equal(entry.timestamp, "2025-01-15T10:30:00.000Z")
462460
})
463461

464462
test("normalizes warn to warning", () => {
465-
const html = formatLogEntry({
463+
const entry = formatLogEntry({
466464
timestamp: "2025-01-15T10:30:00Z",
467465
message: "msg",
468466
level: "warn",
469467
})
470-
assert.ok(html.includes('data-level="warning"'))
468+
assert.equal(entry.level, "warning")
471469
})
472470

473471
test("normalizes fatal to critical", () => {
474-
const html = formatLogEntry({
472+
const entry = formatLogEntry({
475473
timestamp: "2025-01-15T10:30:00Z",
476474
message: "msg",
477475
level: "fatal",
478476
})
479-
assert.ok(html.includes('data-level="critical"'))
477+
assert.equal(entry.level, "critical")
480478
})
481479

482480
test("infers level from message prefix when level is unknown", () => {
483-
const html = formatLogEntry({
481+
const entry = formatLogEntry({
484482
timestamp: "2025-01-15T10:30:00Z",
485483
message: ' INFO 50.35.91.231:0 - "GET / HTTP/1.1" 200',
486484
level: "unknown",
487485
})
488-
assert.ok(html.includes('data-level="info"'))
489-
assert.ok(html.includes("color:#00cccc"))
486+
assert.equal(entry.level, "info")
490487
})
491488

492489
test("defaults to info when level is missing", () => {
493-
const html = formatLogEntry({
490+
const entry = formatLogEntry({
494491
timestamp: "2025-01-15T10:30:00Z",
495492
message: "no level",
496493
level: undefined as any,
497494
})
498-
assert.ok(html.includes('data-level="info"'))
495+
assert.equal(entry.level, "info")
499496
})
500497

501-
test("uses default color for unknown level", () => {
502-
const html = formatLogEntry({
498+
test("preserves unrecognized level verbatim", () => {
499+
const entry = formatLogEntry({
503500
timestamp: "2025-01-15T10:30:00Z",
504501
message: "msg",
505502
level: "trace",
506503
})
507-
assert.ok(html.includes('data-level="trace"'))
508-
assert.ok(html.includes("color:#888"))
504+
assert.equal(entry.level, "trace")
509505
})
510506

511507
test("lowercases level", () => {
512-
const html = formatLogEntry({
508+
const entry = formatLogEntry({
513509
timestamp: "2025-01-15T10:30:00Z",
514510
message: "msg",
515511
level: "ERROR",
516512
})
517-
assert.ok(html.includes('data-level="error"'))
513+
assert.equal(entry.level, "error")
518514
})
519515

520-
test("escapes HTML in message", () => {
521-
const html = formatLogEntry({
516+
test("passes message through unescaped (webview sets it as text)", () => {
517+
const entry = formatLogEntry({
522518
timestamp: "2025-01-15T10:30:00Z",
523519
message: "<script>alert('xss')</script>",
524520
level: "info",
525521
})
526-
assert.ok(html.includes("&lt;script&gt;"))
527-
assert.ok(!html.includes("<script>alert"))
528-
})
529-
530-
test("escapes quotes in level for attribute safety", () => {
531-
const html = formatLogEntry({
532-
timestamp: "2025-01-15T10:30:00Z",
533-
message: "msg",
534-
level: '"onclick="alert(1)',
535-
})
536-
assert.ok(!html.includes('data-level=""onclick'))
537-
assert.ok(html.includes("&quot;"))
522+
assert.equal(entry.message, "<script>alert('xss')</script>")
538523
})
539524

540525
test("handles invalid timestamp gracefully", () => {
541-
const html = formatLogEntry({
526+
const entry = formatLogEntry({
542527
timestamp: "not-a-date",
543528
message: "msg",
544529
level: "info",
545530
})
546-
assert.ok(html.includes("not-a-date"))
531+
assert.equal(entry.timestamp, "not-a-date")
547532
})
548533
})
549534

0 commit comments

Comments
 (0)