fix: close Ticket 11 hardening review gaps

This commit is contained in:
proxyctl coordinator
2026-07-18 02:04:26 +08:00
parent f15ffc2301
commit ed8b527f1f
15 changed files with 1045 additions and 175 deletions
+221 -39
View File
@@ -1,11 +1,13 @@
package tui
import (
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"unicode/utf8"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
@@ -109,6 +111,11 @@ type terminalPage struct {
}
func (p terminalPresentation) Render(state presentationState) string {
// Application snapshots, adapter diagnostics, operation updates, and form
// input are all untrusted presentation data. Strip their controls before any
// semanticTheme style is applied; otherwise an attacker could inject one of
// the same allowlisted SGR sequences that the theme legitimately emits.
state = sanitizePresentationState(state)
width, height := state.width, state.height
if width <= 0 {
width = 80
@@ -147,15 +154,10 @@ func (p terminalPresentation) Render(state presentationState) string {
page.statusExpanded = state.errorExpanded && state.currentError != nil
page.statusOffset = state.errorOffset
page = redactTerminalPage(page)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing. Form
// frames use the stricter boundary that removes the entire userinfo.
frame := p.renderShell(page, width, height)
if state.page == formPage {
return sanitizeStyledText(profile.RedactUserinfoText(frame))
}
return sanitizeStyledText(profile.RedactText(frame))
// Untrusted values were redacted and stripped of controls before semantic
// styling. The final pass therefore removes unexpected terminal controls
// without reparsing clipped, composed prose as if it were a URL.
return sanitizeStyledText(p.renderShell(page, width, height))
}
func renderStatus(state presentationState) string {
@@ -187,16 +189,159 @@ func redactTerminalPage(page terminalPage) terminalPage {
}
func protectRenderedText(value string) string {
return sanitizeStyledText(profile.RedactText(value))
return sanitizeStyledText(value)
}
func sanitizeUntrustedControls(value string) string {
return sanitizeTerminalText(value, false)
}
func sanitizeUntrustedText(value string) string {
return sanitizeUntrustedControls(profile.RedactText(value))
}
func sanitizeUntrustedUserinfoText(value string) string {
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
}
func sanitizePresentationState(state presentationState) presentationState {
state.snapshot.ActiveProfile = sanitizeUntrustedText(state.snapshot.ActiveProfile)
state.snapshot.Profiles = append([]profile.Profile(nil), state.snapshot.Profiles...)
for i := range state.snapshot.Profiles {
state.snapshot.Profiles[i] = sanitizePresentationProfile(state.snapshot.Profiles[i])
}
state.snapshot.Targets = append([]app.TargetSnapshot(nil), state.snapshot.Targets...)
for i := range state.snapshot.Targets {
item := &state.snapshot.Targets[i]
item.Description.Name = sanitizeUntrustedText(item.Description.Name)
item.Description.Summary = sanitizeUntrustedText(item.Description.Summary)
item.Description.Scope = sanitizeUntrustedText(item.Description.Scope)
item.Description.Permissions = sanitizeUntrustedText(item.Description.Permissions)
item.Description.Ownership = sanitizeUntrustedText(item.Description.Ownership)
item.Description.RestartRisk = sanitizeUntrustedText(item.Description.RestartRisk)
item.Description.Details = sanitizeUntrustedText(item.Description.Details)
item.AvailabilityEvidence = sanitizeUntrustedText(item.AvailabilityEvidence)
item.Evidence = sanitizeUntrustedText(item.Evidence)
item.DetectionErr = sanitizePresentationError(item.DetectionErr)
item.PrimaryAction.DisabledReason = sanitizeUntrustedText(item.PrimaryAction.DisabledReason)
item.DriftResolutionActions = append([]app.TargetAction(nil), item.DriftResolutionActions...)
for action := range item.DriftResolutionActions {
item.DriftResolutionActions[action].DisabledReason = sanitizeUntrustedText(item.DriftResolutionActions[action].DisabledReason)
}
}
sanitizeStatus := sanitizeUntrustedText
if state.page == formPage {
// Profile editing treats the identity as private as well as the password.
// Apply that stronger policy to every status/error ingress before styling.
sanitizeStatus = sanitizeUntrustedUserinfoText
}
state.status = sanitizeStatus(state.status)
state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary)
if state.currentError != nil {
current := *state.currentError
current.status.Summary = sanitizeStatus(current.status.Summary)
current.detail = sanitizeStatus(current.detail)
state.currentError = &current
}
if state.confirmation != nil {
confirmation := sanitizePresentationConfirmation(*state.confirmation)
state.confirmation = &confirmation
}
if state.confirmationDialog != nil {
dialog := *state.confirmationDialog
dialog.request = sanitizePresentationConfirmation(dialog.request)
state.confirmationDialog = &dialog
}
if state.profileDelete != nil {
confirmation := *state.profileDelete
confirmation.name = sanitizeUntrustedText(confirmation.name)
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
state.profileDelete = &confirmation
}
state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName)
searchPosition := state.picker.search.Position()
state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value()))
state.picker.search.SetCursor(searchPosition)
state.form = sanitizePresentationForm(state.form)
return state
}
func sanitizePresentationProfile(value profile.Profile) profile.Profile {
value.Name = sanitizeUntrustedText(value.Name)
value.SingleURL = sanitizeUntrustedText(value.SingleURL)
value.HTTPURL = sanitizeUntrustedText(value.HTTPURL)
value.HTTPSURL = sanitizeUntrustedText(value.HTTPSURL)
value.AllURL = sanitizeUntrustedText(value.AllURL)
value.Bypass = append([]string(nil), value.Bypass...)
for i := range value.Bypass {
value.Bypass[i] = sanitizeUntrustedText(value.Bypass[i])
}
return value
}
func sanitizePresentationForm(form formState) formState {
// Preserve credential byte/rune positions in the copied editor state so
// textinput cursor and horizontal viewport calculations stay truthful.
// renderedInput masks userinfo before clipping, and the final form boundary
// redacts it again; this pass only removes terminal controls before styling.
form.originalName = sanitizeUntrustedControls(form.originalName)
form.profile = sanitizePresentationFormProfile(form.profile)
form.inputs = append([]formInput(nil), form.inputs...)
for i := range form.inputs {
form.inputs[i].label = sanitizeUntrustedText(form.inputs[i].label)
position := form.inputs[i].input.Position()
form.inputs[i].input.SetValue(sanitizeUntrustedControls(form.inputs[i].input.Value()))
form.inputs[i].input.SetCursor(position)
}
form.probe.address = sanitizeUntrustedText(form.probe.address)
form.probe.feedback = sanitizeUntrustedText(form.probe.feedback)
form.probe.sourceValue = sanitizeUntrustedText(form.probe.sourceValue)
form.refreshValidation()
return form
}
func sanitizePresentationFormProfile(value profile.Profile) profile.Profile {
value.Name = sanitizeUntrustedControls(value.Name)
value.SingleURL = sanitizeUntrustedControls(value.SingleURL)
value.HTTPURL = sanitizeUntrustedControls(value.HTTPURL)
value.HTTPSURL = sanitizeUntrustedControls(value.HTTPSURL)
value.AllURL = sanitizeUntrustedControls(value.AllURL)
value.Bypass = append([]string(nil), value.Bypass...)
for i := range value.Bypass {
value.Bypass[i] = sanitizeUntrustedControls(value.Bypass[i])
}
return value
}
func sanitizePresentationConfirmation(value app.Confirmation) app.Confirmation {
value.Title = sanitizeUntrustedText(value.Title)
value.Summary = sanitizeUntrustedText(value.Summary)
value.ConfirmLabel = sanitizeUntrustedText(value.ConfirmLabel)
value.CancelLabel = sanitizeUntrustedText(value.CancelLabel)
value.Details = append([]string(nil), value.Details...)
for i := range value.Details {
value.Details[i] = sanitizeUntrustedText(value.Details[i])
}
return value
}
func sanitizePresentationError(err error) error {
if err == nil {
return nil
}
return errors.New(sanitizeUntrustedText(err.Error()))
}
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
// sanitizeStyledText removes terminal-control injection while retaining only
// the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands,
// cursor movement, concealment, carriage returns, and private C1 controls are
// never meaningful application text and must not reach a rendered frame.
func sanitizeStyledText(value string) string {
// sanitizeStyledText is the final defense after semantic styling. Untrusted
// values have already passed sanitizeUntrustedText, so the only SGR retained
// here is output generated by semanticTheme.
func sanitizeStyledText(value string) string { return sanitizeTerminalText(value, true) }
func sanitizeTerminalText(value string, allowThemeSGR bool) string {
var safe strings.Builder
for i := 0; i < len(value); {
if value[i] == '\x1b' {
@@ -206,7 +351,7 @@ func sanitizeStyledText(value string) string {
end++
}
if end < len(value) {
if value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
if allowThemeSGR && value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
safe.WriteString(value[i : end+1])
}
i = end + 1
@@ -216,30 +361,44 @@ func sanitizeStyledText(value string) string {
continue
}
if i+1 < len(value) && value[i+1] == ']' {
i += 2
for i < len(value) {
if value[i] == '\a' {
i++
break
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
i += 2
break
}
i++
}
i = skipTerminalString(value, i+2)
continue
}
i++
continue
}
if value[i] == 0x9b {
end := i + 1
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
end++
}
if end < len(value) {
i = end + 1
} else {
i = len(value)
}
continue
}
if value[i] == 0x9d {
i = skipTerminalString(value, i+1)
continue
}
switch value[i] {
case '\n':
safe.WriteByte('\n')
case '\t':
safe.WriteString(" ")
default:
if value[i] >= 0x20 && value[i] != 0x7f {
if value[i] >= utf8.RuneSelf {
r, size := utf8.DecodeRuneInString(value[i:])
if r != utf8.RuneError || size > 1 {
if r != '\ufeff' && !(r >= 0x80 && r <= 0x9f) && !(r >= 0x202a && r <= 0x202e) && !(r >= 0x2066 && r <= 0x2069) {
safe.WriteString(value[i : i+size])
}
i += size
continue
}
} else if value[i] >= 0x20 && value[i] != 0x7f {
safe.WriteByte(value[i])
}
}
@@ -248,7 +407,7 @@ func sanitizeStyledText(value string) string {
var visible strings.Builder
for _, r := range safe.String() {
if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
if r == '\ufeff' || (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
continue
}
visible.WriteRune(r)
@@ -256,10 +415,33 @@ func sanitizeStyledText(value string) string {
return visible.String()
}
func skipTerminalString(value string, i int) int {
for i < len(value) {
if value[i] == '\a' || value[i] == 0x9c {
return i + 1
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
return i + 2
}
i++
}
return i
}
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
header := viewportContent(page.header, width, lineCount(page.header), 0)
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
headerHeight := lineCount(header)
footerHeight := lineCount(footer)
baseSeparators := 0
if header != "" {
baseSeparators++
}
if footer != "" || page.status != "" {
baseSeparators++
}
status := ""
if page.status != "" {
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
@@ -267,21 +449,21 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
statusOffset := 0
if page.statusExpanded {
statusHeight = min(lineCount(wrapped), max(3, height/2))
// The compact dashboard needs ten rows to retain both panes. Bound
// expanded errors to the remaining terminal budget instead of
// allowing their viewport to push the frame beyond the terminal.
minimumBodyHeight := 1
if page.dashboard != nil {
minimumBodyHeight = 10
}
statusHeight = min(statusHeight, max(1, height-headerHeight-footerHeight-baseSeparators-minimumBodyHeight))
statusOffset = page.statusOffset
}
status = viewportContent(wrapped, width, statusHeight, statusOffset)
}
headerHeight := lineCount(header)
footerHeight := lineCount(footer)
statusHeight := lineCount(status)
separators := 0
if header != "" {
separators++
}
if footer != "" || status != "" {
separators++
}
separators := baseSeparators
bodyHeight := height - headerHeight - footerHeight - statusHeight - separators
if bodyHeight < 1 {
bodyHeight = 1