Skip to content

Commit 3218d72

Browse files
authored
fix(security): resolve open code-scanning alerts (XSS, int-conversion, cookie, CI perms) (#108)
Addresses all 12 open alerts from GitHub code scanning: High: - go/incorrect-integer-conversion (classifier.go, skills/importer.go): both inet_aton-style IP parsers now reject out-of-range parts instead of silently truncating (300.1.1.1 no longer parses as 44.1.1.1; 10.300.1.1 no longer slips into the private range as 10.44.1.1), matching browser semantics and protecting the SSRF/internal-IP checks downstream. - go/incomplete-url-scheme-check (browser_tool.go): link extraction now filters javascript:, data:, and vbscript: case-insensitively. - js/xss (ui/app.js): formatNum coerces to a finite number and the latency stat is coerced, so crafted non-numeric values can never reach innerHTML as markup. - js/xss-through-dom (ui/app.js): renderFileChips builds DOM nodes with textContent instead of innerHTML. - js/double-escaping (ui/app.js): escapeAttr replaces & first so quote entities are no longer double-escaped. Medium: - go/cookie-secure-not-set (serve.go): the WS-token cookie sets Secure: true. The UI always also authenticates via WebSocket subprotocol and /api header, so browsers that drop the cookie lose nothing. - actions/missing-workflow-permissions: top-level permissions: contents: read in test.yml and release.yml (the release job keeps its explicit contents: write). Regression tests: IP bound cases in both packages, browser scheme-filter test. Full test suite, vet, golangci-lint, and node --check all pass.
1 parent 285719f commit 3218d72

10 files changed

Lines changed: 131 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ on:
44
push:
55
tags: ["v*"]
66

7+
permissions:
8+
contents: read
9+
710
jobs:
811
build:
912
runs-on: ubuntu-latest

.github/workflows/test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
pull_request:
77
branches: [main]
88

9+
permissions:
10+
contents: read
11+
912
jobs:
1013
test:
1114
runs-on: ubuntu-latest

cmd/odek/browser_tool.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,14 @@ func parseHTML(ctx context.Context, html, pageURL string, status int) browserSna
408408
}
409409
href := strings.TrimSpace(m[1])
410410
text := strings.TrimSpace(m[2])
411-
if href == "" || text == "" || href == "#" || strings.HasPrefix(href, "javascript:") {
411+
// Skip non-navigable schemes. The check is case-insensitive and
412+
// covers javascript:, data:, and vbscript: — all of which can carry
413+
// script/active content the agent must never be sent into.
414+
lowerHref := strings.ToLower(href)
415+
if href == "" || text == "" || href == "#" ||
416+
strings.HasPrefix(lowerHref, "javascript:") ||
417+
strings.HasPrefix(lowerHref, "data:") ||
418+
strings.HasPrefix(lowerHref, "vbscript:") {
412419
continue
413420
}
414421
// Skip duplicates

cmd/odek/browser_tool_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,37 @@ func TestBrowser_WrapsLinkURL(t *testing.T) {
596596
t.Errorf("link URL should be wrapped as untrusted, got %q", r.Elements[0].URL)
597597
}
598598
}
599+
600+
// TestBrowser_SkipsDangerousLinkSchemes pins the incomplete-scheme-check fix:
601+
// links with script/active-content schemes (javascript:, data:, vbscript:,
602+
// any casing) are excluded from the extracted interactive elements.
603+
func TestBrowser_SkipsDangerousLinkSchemes(t *testing.T) {
604+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
605+
w.Write([]byte(`<html><body>
606+
<a href="javascript:alert(1)">js</a>
607+
<a href="JavaScript:alert(1)">js-caps</a>
608+
<a href="data:text/html,<script>alert(1)</script>">data</a>
609+
<a href="vbscript:msgbox(1)">vbs</a>
610+
<a href="/safe">safe link</a>
611+
</body></html>`))
612+
}))
613+
defer ts.Close()
614+
615+
b := newTestBrowserTool()
616+
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
617+
618+
result := callJSON(t, b, `{"action":"snapshot"}`)
619+
var r struct {
620+
Content string `json:"content"`
621+
}
622+
mustUnmarshal(t, result, &r)
623+
624+
for _, bad := range []string{"javascript:", "JavaScript:", "data:", "vbscript:"} {
625+
if strings.Contains(r.Content, bad) {
626+
t.Errorf("snapshot contains %q link, want it filtered: %q", bad, r.Content)
627+
}
628+
}
629+
if !strings.Contains(r.Content, "/safe") {
630+
t.Errorf("snapshot missing the safe link: %q", r.Content)
631+
}
632+
}

cmd/odek/serve.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,6 +1783,13 @@ func handleStatic(wsToken string) http.HandlerFunc {
17831783
Path: "/",
17841784
SameSite: http.SameSiteStrictMode,
17851785
HttpOnly: true,
1786+
// Secure is set even though the server usually runs on
1787+
// plain-http loopback: modern browsers treat localhost as a
1788+
// potentially trustworthy origin and accept Secure cookies
1789+
// there, and the UI always sends the token as a WebSocket
1790+
// subprotocol (and /api header), so a browser that drops
1791+
// the cookie loses nothing.
1792+
Secure: true,
17861793
// No explicit MaxAge/Expires so the cookie is a session cookie.
17871794
})
17881795
data = []byte(strings.Replace(string(data), "{{ODEK_WS_TOKEN}}", wsToken, 1))

cmd/odek/ui/app.js

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ function formatErrorMessage(msg) {
165165

166166
// ── Number formatting ──
167167
function formatNum(n) {
168+
// Coerce to a finite number so a crafted (non-numeric) value can never be
169+
// reinterpreted as HTML when the result lands in an innerHTML assignment.
170+
n = Number(n);
171+
if (!isFinite(n)) n = 0;
168172
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k';
169173
return String(n);
170174
}
@@ -305,8 +309,10 @@ function connect() {
305309
if (lastAssistant) {
306310
const stats = document.createElement('div');
307311
stats.className = 'msg-stats';
312+
const lat = Number(event.latency);
313+
const latSafe = isFinite(lat) ? lat : 0;
308314
const spans = [];
309-
spans.push('<span title="Response time">⚡ ' + (event.latency < 1 ? (event.latency * 1000).toFixed(0) + 'ms' : event.latency.toFixed(1) + 's') + '</span>');
315+
spans.push('<span title="Response time">⚡ ' + (latSafe < 1 ? (latSafe * 1000).toFixed(0) + 'ms' : latSafe.toFixed(1) + 's') + '</span>');
310316
if (event.contextTokens != null) spans.push('<span title="Input tokens (prompt)">' + formatNum(event.contextTokens) + ' in</span>');
311317
if (event.outputTokens != null) spans.push('<span title="Output tokens (completion)">' + formatNum(event.outputTokens) + ' out</span>');
312318
// Cache metrics — show only when non-zero
@@ -1268,7 +1274,9 @@ function escapeHtml(s) {
12681274

12691275
function escapeAttr(s) {
12701276
if (!s) return '';
1271-
return s.replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/&/g,'&amp;');
1277+
// & must be replaced first — doing it last double-escapes the entities
1278+
// introduced by the quote replacements (&quot; → &amp;quot;).
1279+
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
12721280
}
12731281

12741282
// ── Markdown to HTML (safe, no DOMPurify needed since we control input) ──
@@ -1460,18 +1468,33 @@ function clearAttachedFiles() {
14601468
}
14611469

14621470
function renderFileChips() {
1463-
if (attachedFiles.length === 0) {
1464-
fileChips.innerHTML = '';
1465-
return;
1466-
}
1467-
fileChips.innerHTML = attachedFiles.map((f, i) =>
1468-
'<span class="file-chip">' +
1469-
'<span class="chip-icon">📎</span>' +
1470-
'<span class="chip-name">' + escapeHtml(f.name) + '</span>' +
1471-
'<span class="chip-size">' + formatFileSize(f.size) + '</span>' +
1472-
'<span class="chip-remove" onclick="removeAttachedFile(' + i + ')">✕</span>' +
1473-
'</span>'
1474-
).join('');
1471+
// Build nodes with textContent rather than innerHTML so a file name can
1472+
// never be reinterpreted as markup.
1473+
fileChips.textContent = '';
1474+
attachedFiles.forEach((f, i) => {
1475+
const chip = document.createElement('span');
1476+
chip.className = 'file-chip';
1477+
1478+
const icon = document.createElement('span');
1479+
icon.className = 'chip-icon';
1480+
icon.textContent = '📎';
1481+
1482+
const name = document.createElement('span');
1483+
name.className = 'chip-name';
1484+
name.textContent = f.name;
1485+
1486+
const size = document.createElement('span');
1487+
size.className = 'chip-size';
1488+
size.textContent = formatFileSize(f.size);
1489+
1490+
const remove = document.createElement('span');
1491+
remove.className = 'chip-remove';
1492+
remove.textContent = '✕';
1493+
remove.addEventListener('click', () => removeAttachedFile(i));
1494+
1495+
chip.append(icon, name, size, remove);
1496+
fileChips.appendChild(chip);
1497+
});
14751498
}
14761499

14771500
function readFileAsText(file) {

internal/danger/classifier.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,21 @@ func parseBrowserIP(host string) net.IP {
463463
nums = append(nums, uint32(val))
464464
}
465465

466+
// inet_aton requires every leading part to fit in one byte and the final
467+
// part to fit in the remaining bytes (a.b → b ≤ 0xFFFFFF, a.b.c →
468+
// c ≤ 0xFFFF, a.b.c.d → d ≤ 0xFF). Reject out-of-range parts instead of
469+
// silently truncating them — "300.1.1.1" must not parse as 44.1.1.1 and
470+
// mislead the internal-IP checks downstream.
471+
for i, n := range nums {
472+
max := uint64(0xFF)
473+
if i == len(nums)-1 {
474+
max = (uint64(1) << (8 * uint(5-len(nums)))) - 1
475+
}
476+
if uint64(n) > max {
477+
return nil
478+
}
479+
}
480+
466481
switch len(nums) {
467482
case 1:
468483
// Single number: full 32-bit address

internal/danger/whitebox_coverage_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,12 @@ func TestParseBrowserIP(t *testing.T) {
314314
{"::1", false, "::1"}, // IPv6
315315
{"1.2.3.4.5", true, ""}, // too many parts
316316
{"99999999999.1", true, ""}, // part exceeds 32 bits → nil
317+
{"300.1.1.1", true, ""}, // leading part > 0xFF → nil, not 44.1.1.1
318+
{"256.1", true, ""}, // leading part > 0xFF in short form
319+
{"1.16777216", true, ""}, // final part > 0xFFFFFF in a.b form
320+
{"1.2.65536", true, ""}, // final part > 0xFFFF in a.b.c form
321+
{"1.2.3.256", true, ""}, // final part > 0xFF in a.b.c.d form
322+
{"255.255.255.255", false, "255.255.255.255"}, // boundary: all parts at max
317323
{"0xZZ.0.0.1", true, ""}, // bad hex
318324
{"not.an.ip.addr", true, ""}, // non-numeric
319325
{"", true, ""}, // empty

internal/skills/importer.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,19 @@ func parsePrivateIP(host string) net.IP {
412412
}
413413
nums = append(nums, uint32(val))
414414
}
415+
// inet_aton requires every leading part to fit in one byte and the final
416+
// part to fit in the remaining bytes (a.b → b ≤ 0xFFFFFF, a.b.c →
417+
// c ≤ 0xFFFF, a.b.c.d → d ≤ 0xFF). Reject out-of-range parts instead of
418+
// silently truncating them (e.g. "300.1.1.1" → 44.1.1.1).
419+
for i, n := range nums {
420+
max := uint64(0xFF)
421+
if i == len(nums)-1 {
422+
max = (uint64(1) << (8 * uint(5-len(nums)))) - 1
423+
}
424+
if uint64(n) > max {
425+
return nil
426+
}
427+
}
415428
switch len(nums) {
416429
case 1:
417430
return net.IPv4(byte(nums[0]>>24), byte(nums[0]>>16), byte(nums[0]>>8), byte(nums[0]))

internal/skills/importer_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ func TestIsPrivateHost(t *testing.T) {
424424
{"0x7f000001", true}, // hex 127.0.0.1
425425
{"127.1", true}, // short form 127.0.0.1
426426
{"0x0.0x0.0x0.0x0", true}, // hex-dotted
427+
// Out-of-range parts are rejected (browser semantics: invalid IP →
428+
// hostname), not silently truncated — 10.300.1.1 must not parse as
429+
// 10.44.1.1 and slip into the private range.
430+
{"10.300.1.1", false},
431+
{"300.1.1.1", false},
427432
// RFC 6598 carrier-grade NAT
428433
{"100.64.0.1", true},
429434
{"100.127.255.254", true},

0 commit comments

Comments
 (0)