Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
)

replace github.com/vito/midterm v0.2.3 => github.com/dcosson/midterm v0.2.4-0.20260511235854-99f47ec830a4
replace github.com/vito/midterm v0.2.3 => github.com/dcosson/midterm v0.2.4-dcosson.1

require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dcosson/destructive-command-guard-go v0.2.3 h1:JKgtpXtleRjzRZFTGBM83pSxT3mkKfujrPb5AC+7oeo=
github.com/dcosson/destructive-command-guard-go v0.2.3/go.mod h1:5HJPETCjVmyQ5IiF1EKnJzlngxXUDlJVbMYCHsAmoDo=
github.com/dcosson/midterm v0.2.4-0.20260511235854-99f47ec830a4 h1:d0m8RWqLI3lQF3Rv2vinCpno6/bbyh55nImT6qF2X+c=
github.com/dcosson/midterm v0.2.4-0.20260511235854-99f47ec830a4/go.mod h1:WkbqZBIhH4jfxXkE2bjhosM1BdF/dCp7sR4x9wQB6fA=
github.com/dcosson/midterm v0.2.4-dcosson.1 h1:ORRLUKms74cAjTa8qz84qRLPSHoEWCHAa4yFvG22iQU=
github.com/dcosson/midterm v0.2.4-dcosson.1/go.mod h1:WkbqZBIhH4jfxXkE2bjhosM1BdF/dCp7sR4x9wQB6fA=
github.com/dcosson/treesitter-go v0.1.0 h1:E+tXGxZTJT7wPlAAANLMbejaNhreemIKiZgxj4oXV5I=
github.com/dcosson/treesitter-go v0.1.0/go.mod h1:ROuyUNRSakznobALBQA2dhJXIEn9JvfG9CGSg6utrpE=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
Expand Down
81 changes: 80 additions & 1 deletion internal/session/client/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ func (c *Client) renderHistoryEntry(buf *bytes.Buffer, entry virtualterminal.Scr
}
var pos int
var lastFormat midterm.Format
var lastURL string
first := true
for _, run := range entry.Runs {
if pos >= cols {
Expand All @@ -163,6 +164,14 @@ func (c *Client) renderHistoryEntry(buf *bytes.Buffer, entry virtualterminal.Scr
lastFormat = f
first = false
}
// Sanitize before tracking so an unsafe URL is treated as no-link in
// the state machine — matches RenderLineFrom and avoids a stray
// end-of-row close when no open was emitted.
curURL := sanitizeOSC8URL(run.URL)
if curURL != lastURL {
writeOSC8BoundaryStr(buf, lastURL, curURL)
lastURL = curURL
}
contentEnd := end
if contentEnd > len(entry.Content) {
contentEnd = len(entry.Content)
Expand All @@ -172,9 +181,56 @@ func (c *Client) renderHistoryEntry(buf *bytes.Buffer, entry virtualterminal.Scr
}
pos = end
}
if lastURL != "" {
buf.WriteString("\033]8;;\033\\")
}
buf.WriteString("\033[0m")
}

// writeOSC8BoundaryStr emits the OSC 8 close/open transitions between two
// URLs. An explicit close precedes any open — OSC 8 has no stack and a
// "new open inside an old open" can confuse terminals, so we always reset.
// next is sanitized against terminator-equivalent control bytes (ESC, BEL,
// C0/DEL) before emission; an unsafe input drops the link entirely rather
// than risk re-injecting attacker bytes into the outer terminal.
func writeOSC8BoundaryStr(buf *bytes.Buffer, prev, next string) {
if prev != "" {
buf.WriteString("\033]8;;\033\\")
}
if next == "" {
return
}
safe := sanitizeOSC8URL(next)
if safe == "" {
return
}
buf.WriteString("\033]8;;")
buf.WriteString(safe)
buf.WriteString("\033\\")
}

// sanitizeOSC8URL rejects URLs containing any byte that could break out of an
// OSC 8 sequence on the outer terminal: ESC (0x1B) is the ST half, BEL (0x07)
// is the alternate terminator, and other C0 controls / DEL can lead to
// terminal state corruption. The xterm OSC 8 spec restricts URIs to printable
// ASCII anyway. Returns "" to signal "drop this link" — callers treat that
// as no-link rather than attempting partial recovery.
func sanitizeOSC8URL(s string) string {
if s == "" {
return ""
}
for i := 0; i < len(s); i++ {
b := s[i]
// Reject anything below 0x20 (C0 controls including ESC, BEL, NUL,
// CR, LF, etc.) and 0x7F (DEL). Bytes >= 0x80 are UTF-8 continuation
// bytes; most terminals accept UTF-8 in OSC parameters.
if b < 0x20 || b == 0x7F {
return ""
}
}
return s
}

// renderScrollIndicator draws the "(scrolling)" indicator at row 1, right-aligned.
func (c *Client) renderScrollIndicator(buf *bytes.Buffer) {
indicator := "(scrolling)"
Expand Down Expand Up @@ -211,21 +267,41 @@ func (c *Client) renderScrollIndicator(buf *bytes.Buffer) {
// RenderLineFrom writes one row of the given terminal to buf.
// This uses explicit SGR resets between format regions to prevent
// background colors from bleeding across regions (midterm's RenderLine
// does not reset between regions).
// does not reset between regions). OSC 8 hyperlinks are emitted around
// runs of cells sharing a URL ID — opened on entry, closed on exit, so
// each row stands alone (the next row will reopen if its first run is
// still linked).
func (c *Client) RenderLineFrom(buf *bytes.Buffer, vt *midterm.Terminal, row int) {
if row >= len(vt.Content) {
return
}
line := vt.Content[row]
var pos int
var lastFormat midterm.Format
var lastURL string
var lastURLID uint32
for region := range vt.Format.Regions(row) {
f := region.F
if f != lastFormat {
buf.WriteString("\033[0m")
buf.WriteString(f.Render())
lastFormat = f
}
// Resolve+sanitize URLs once per ID; cache via lastURLID so multiple
// adjacent regions with the same ID skip the lookup.
var curURL string
if region.URLID != 0 {
if region.URLID == lastURLID {
curURL = lastURL
} else {
curURL = sanitizeOSC8URL(vt.URL(region.URLID))
}
}
if curURL != lastURL {
writeOSC8BoundaryStr(buf, lastURL, curURL)
lastURL = curURL
}
lastURLID = region.URLID
end := pos + region.Size
if pos < len(line) {
contentEnd := end
Expand All @@ -236,6 +312,9 @@ func (c *Client) RenderLineFrom(buf *bytes.Buffer, vt *midterm.Terminal, row int
}
pos = end
}
if lastURL != "" {
buf.WriteString("\033]8;;\033\\")
}
buf.WriteString("\033[0m")
}

Expand Down
182 changes: 182 additions & 0 deletions internal/session/client/render_osc8_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package client

import (
"bytes"
"fmt"
"strings"
"testing"

"github.com/vito/midterm"

"h2/internal/session/virtualterminal"
)

// osc8 builds the OSC 8 open sequence for uri (empty closes the link).
func osc8(uri string) string {
return fmt.Sprintf("\x1b]8;;%s\x1b\\", uri)
}

const osc8Close = "\x1b]8;;\x1b\\"

func TestRenderLineFrom_EmitsOSC8AroundHyperlink(t *testing.T) {
c := newTestClient(2, 20)
fmt.Fprintf(c.VT.Vt, "%shello%s world", osc8("https://example.com"), osc8(""))

var buf bytes.Buffer
c.RenderLineFrom(&buf, c.VT.Vt, 0)
got := buf.String()

openSeq := osc8("https://example.com")
if !strings.Contains(got, openSeq) {
t.Fatalf("missing OSC 8 open sequence: %q", got)
}
if !strings.Contains(got, osc8Close) {
t.Fatalf("missing OSC 8 close sequence: %q", got)
}
if strings.Index(got, openSeq) > strings.Index(got, "hello") {
t.Fatalf("OSC 8 open should precede 'hello': %q", got)
}
// The close must come after "hello" but before the trailing " world", so
// that " world" is unlinked.
closeIdx := strings.Index(got, osc8Close)
helloIdx := strings.Index(got, "hello")
worldIdx := strings.Index(got, " world")
if !(helloIdx < closeIdx && closeIdx < worldIdx) {
t.Fatalf("OSC 8 close should sit between hello and world: %q", got)
}
}

func TestRenderLineFrom_NoOSC8ForUnlinkedRow(t *testing.T) {
c := newTestClient(2, 20)
fmt.Fprint(c.VT.Vt, "just text")

var buf bytes.Buffer
c.RenderLineFrom(&buf, c.VT.Vt, 0)
got := buf.String()

if strings.Contains(got, "\x1b]8;") {
t.Fatalf("unexpected OSC 8 sequence in unlinked row: %q", got)
}
}

func TestRenderLineFrom_ClosesBetweenAdjacentLinks(t *testing.T) {
// Two back-to-back links with no gap: a close must precede the second
// open so the outer terminal sees clean URL transitions.
c := newTestClient(2, 20)
fmt.Fprintf(c.VT.Vt, "%sa%s%sb%s",
osc8("https://x"), osc8(""),
osc8("https://y"), osc8(""))

var buf bytes.Buffer
c.RenderLineFrom(&buf, c.VT.Vt, 0)
got := buf.String()

openX := osc8("https://x")
openY := osc8("https://y")
idxOpenX := strings.Index(got, openX)
idxOpenY := strings.Index(got, openY)
if idxOpenX < 0 || idxOpenY < 0 {
t.Fatalf("both opens should appear: %q", got)
}
// Between the two opens there must be at least one close.
between := got[idxOpenX:idxOpenY]
if !strings.Contains(between, osc8Close) {
t.Fatalf("expected a close between adjacent links: %q", got)
}
}

func TestRenderHistoryEntry_EmitsOSC8(t *testing.T) {
c := newTestClient(2, 20)
entry := virtualterminal.ScrollHistoryEntry{
Content: []rune("foobar"),
Runs: []virtualterminal.FormatRun{
{Size: 3, Format: midterm.Format{}, URL: "https://example.com"},
{Size: 3, Format: midterm.Format{}, URL: ""},
},
}
var buf bytes.Buffer
c.renderHistoryEntry(&buf, entry)
got := buf.String()

openSeq := osc8("https://example.com")
if !strings.Contains(got, openSeq) {
t.Fatalf("missing OSC 8 open: %q", got)
}
if !strings.Contains(got, osc8Close) {
t.Fatalf("missing OSC 8 close: %q", got)
}
// Close must appear between "foo" (linked) and "bar" (unlinked).
idxFoo := strings.Index(got, "foo")
idxClose := strings.Index(got, osc8Close)
idxBar := strings.Index(got, "bar")
if !(idxFoo < idxClose && idxClose < idxBar) {
t.Fatalf("close should sit between foo and bar: %q", got)
}
}

// Note: midterm's OSC parser uses ESC and BEL as OSC terminators, so it
// truncates URIs at those bytes before they ever reach our URL table — making
// the live render path naturally safe against the most obvious injection. The
// renderer's own sanitizer is defense in depth against future parser changes,
// and the history path (which can carry arbitrary URL strings via FormatRun)
// is where we exercise it end-to-end below.

func TestRenderHistoryEntry_RejectsMaliciousURI(t *testing.T) {
c := newTestClient(2, 20)
bad := "https://evil\x07injected"
entry := virtualterminal.ScrollHistoryEntry{
Content: []rune("hi"),
Runs: []virtualterminal.FormatRun{
{Size: 2, Format: midterm.Format{}, URL: bad},
},
}
var buf bytes.Buffer
c.renderHistoryEntry(&buf, entry)
got := buf.String()
if strings.Contains(got, bad) || strings.Contains(got, "injected") {
t.Fatalf("malicious URI leaked through history render: %q", got)
}
if strings.Contains(got, "\x1b]8;;https") {
t.Fatalf("rejected URI must not produce an OSC 8 open: %q", got)
}
}

func TestSanitizeOSC8URL(t *testing.T) {
cases := []struct {
in, want string
}{
{"", ""},
{"https://example.com", "https://example.com"},
{"https://example.com/a?b=c&d=e", "https://example.com/a?b=c&d=e"},
{"https://example.com/\x1bevil", ""}, // ESC
{"https://example.com/\x07bell", ""}, // BEL
{"https://example.com/\nnewline", ""}, // LF
{"https://example.com/\x00null", ""}, // NUL
{"https://example.com/\x7fdel", ""}, // DEL
{"https://example.com/π", "https://example.com/π"}, // UTF-8 OK
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := sanitizeOSC8URL(tc.in)
if got != tc.want {
t.Fatalf("sanitizeOSC8URL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

func TestRenderHistoryEntry_NoOSC8WhenAllRunsUnlinked(t *testing.T) {
c := newTestClient(2, 20)
entry := virtualterminal.ScrollHistoryEntry{
Content: []rune("plain"),
Runs: []virtualterminal.FormatRun{
{Size: 5, Format: midterm.Format{}, URL: ""},
},
}
var buf bytes.Buffer
c.renderHistoryEntry(&buf, entry)
got := buf.String()
if strings.Contains(got, "\x1b]8;") {
t.Fatalf("unexpected OSC 8 in unlinked entry: %q", got)
}
}
Loading
Loading