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
23 changes: 15 additions & 8 deletions internal/handlers/device_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,22 @@ func (h *DeviceSessionHandler) ListDevices(c *gin.Context) {
devices := make([]templates.LoginSessionDisplay, 0, len(rows))
for i := range rows {
ls := &rows[i]
// Parse the raw User-Agent on render so the Browser/OS labels carry their
// version (rendered on a second line) for both new and pre-existing rows.
// The cached ls.Device/Browser/OS columns are intentionally not read
// here — UserAgent is the single source of truth for display.
agent := services.ParseUserAgent(ls.UserAgent)
devices = append(devices, templates.LoginSessionDisplay{
ID: ls.ID,
Device: ls.Device,
Browser: ls.Browser,
OS: ls.OS,
IP: ls.IP,
CreatedAt: ls.CreatedAt,
LastSeenAt: ls.LastSeenAt,
IsCurrent: currentHash != "" && currentHash == ls.SIDHash,
ID: ls.ID,
Device: agent.Device,
Browser: agent.Browser,
BrowserVersion: agent.BrowserVersion,
OS: agent.OS,
OSVersion: agent.OSVersion,
IP: ls.IP,
CreatedAt: ls.CreatedAt,
LastSeenAt: ls.LastSeenAt,
IsCurrent: currentHash != "" && currentHash == ls.SIDHash,
})
}

Expand Down
2 changes: 2 additions & 0 deletions internal/services/login_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func TestLoginSession_EstablishAndGetActive(t *testing.T) {
require.NotNil(t, ls)
assert.Equal(t, userID, ls.UserID)
assert.Equal(t, "192.0.2.10", ls.IP)
// Establish caches the bare parsed name (the columns are forensic only;
// display re-parses the raw UserAgent, so versions are not stored here).
assert.Equal(t, "Chrome", ls.Browser)
assert.Equal(t, "macOS", ls.OS)
assert.True(t, ls.IsActive())
Expand Down
74 changes: 62 additions & 12 deletions internal/services/user_agent.go
Original file line number Diff line number Diff line change
@@ -1,32 +1,53 @@
package services

import (
"strings"

"github.com/mileusna/useragent"
)

// uaUnknown is the fallback used for any User-Agent field that cannot be
// determined (empty UA, CLI/curl, bots, or unrecognized strings).
const uaUnknown = "Unknown"

// ParsedUserAgent holds the display-only device/browser/OS labels derived from
// a raw User-Agent header.
// Display labels are bounded so a pathological User-Agent can't blow out the
// Active Sessions table layout: the parser surfaces an unrecognized,
// no-delimiter UA verbatim as the "name" (e.g. a 250-char string), and version
// tokens are equally attacker-controlled.
const (
maxLabelRunes = 48
maxVersionRunes = 24
)

// ParsedUserAgent holds the display-only labels derived from a raw User-Agent
// header. Name and version are kept as separate fields so the UI can render them
// on two lines (e.g. "Chrome" over "120.0.0.0").
type ParsedUserAgent struct {
Device string
Browser string
OS string
Device string
Browser string // browser name only, e.g. "Chrome" (never empty; "Unknown" fallback)
BrowserVersion string // version, e.g. "120.0.0.0"; "" when no usable (digit-leading) version
OS string // OS name only, e.g. "macOS" (never empty; "Unknown" fallback)
OSVersion string // version, e.g. "10.15.7"; "" when no usable version (e.g. Linux reports an arch)
}

// ParseUserAgent extracts coarse device, browser, and OS labels from a raw
// User-Agent string. It never panics and falls back to "Unknown" for any field
// the parser cannot resolve, so empty, CLI (curl), and bot user-agents are all
// handled gracefully. The pure-Go mileusna/useragent parser keeps the build
// cgo-free (cross-compile uses CGO_ENABLED=0).
// User-Agent string. Browser and OS names are returned separately from their
// version, each empty when the parser reports no usable (digit-leading) version
// — the architecture string Linux/ChromeOS report in the version slot (e.g.
// "x86_64") and any non-numeric junk are dropped rather than shown as a fake
// version. Names and versions are length-bounded for display. It never panics
// and falls back to "Unknown" for any name the parser cannot resolve, so empty,
// CLI (curl), and bot user-agents are all handled gracefully. The pure-Go
// mileusna/useragent parser keeps the build cgo-free (cross-compile uses
// CGO_ENABLED=0).
func ParseUserAgent(raw string) ParsedUserAgent {
ua := useragent.Parse(raw)
return ParsedUserAgent{
Device: deviceLabel(ua),
Browser: orUnknown(ua.Name),
OS: orUnknown(ua.OS),
Device: clampLabel(deviceLabel(ua)),
Browser: clampLabel(orUnknown(ua.Name)),
BrowserVersion: displayVersion(ua.Version),
OS: clampLabel(orUnknown(ua.OS)),
OSVersion: displayVersion(ua.OSVersion),
}
}

Expand Down Expand Up @@ -56,3 +77,32 @@ func orUnknown(s string) string {
}
return s
}

// displayVersion normalizes a parser version string for display: it trims, drops
// values that don't begin with a digit (so a Linux/ChromeOS architecture like
// "x86_64" or any non-numeric junk shows as no version rather than a fake one),
// and bounds the length. Returns "" when there is no usable version, so the UI
// falls back to the bare name.
func displayVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" || v[0] < '0' || v[0] > '9' {
return ""
}
return truncateRunes(v, maxVersionRunes)
}

// clampLabel bounds a display name to maxLabelRunes.
func clampLabel(s string) string {
return truncateRunes(s, maxLabelRunes)
}

// truncateRunes returns s unchanged when it fits in limit runes, else the first
// limit runes with an ellipsis appended. Rune-based so it never splits a UTF-8
// byte sequence.
func truncateRunes(s string, limit int) string {
r := []rune(s)
if len(r) <= limit {
return s
}
return string(r[:limit]) + "…"
}
117 changes: 90 additions & 27 deletions internal/services/user_agent_test.go
Original file line number Diff line number Diff line change
@@ -1,61 +1,124 @@
package services

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseUserAgent(t *testing.T) {
tests := []struct {
name string
ua string
wantBrowser string
wantOS string
wantDevice string
name string
ua string
wantBrowser string
wantBrowserVersion string
wantOS string
wantOSVersion string
wantDevice string
}{
{
name: "Chrome on macOS",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
wantBrowser: "Chrome",
wantOS: "macOS",
wantDevice: "Desktop",
name: "Chrome on macOS splits name and full version",
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
// Version is rendered on its own line, so the complete value is shown.
wantBrowser: "Chrome",
wantBrowserVersion: "120.0.0.0",
wantOS: "macOS",
wantOSVersion: "10.15.7",
wantDevice: "Desktop",
},
{
name: "Firefox on Windows",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
wantBrowser: "Firefox",
wantOS: "Windows",
wantDevice: "Desktop",
name: "Firefox on Windows keeps the full OS version",
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
// Windows OSVersion parses as "10.0"; the full value is shown verbatim.
wantBrowser: "Firefox",
wantBrowserVersion: "121.0",
wantOS: "Windows",
wantOSVersion: "10.0",
wantDevice: "Desktop",
},
{
name: "empty UA falls back to Unknown",
ua: "",
wantBrowser: uaUnknown,
wantOS: uaUnknown,
wantDevice: uaUnknown,
name: "empty UA falls back to Unknown with no versions",
ua: "",
// Names fall back to Unknown; versions stay empty so the UI renders
// only the name line (no dangling version row).
wantBrowser: uaUnknown,
wantBrowserVersion: "",
wantOS: uaUnknown,
wantOSVersion: "",
wantDevice: uaUnknown,
},
{
name: "curl CLI UA is named but OS/device fall back to Unknown",
name: "curl CLI UA exposes its full parsed version",
ua: "curl/8.4.0",
// The parser recognizes the curl client name; the fields it cannot
// determine (OS, device) fall back to Unknown rather than panicking.
wantBrowser: "curl",
wantOS: uaUnknown,
wantDevice: uaUnknown,
// The parser resolves curl's name and version (8.4.0); the OS/device
// it cannot determine fall back to Unknown rather than panicking.
wantBrowser: "curl",
wantBrowserVersion: "8.4.0",
wantOS: uaUnknown,
wantOSVersion: "",
wantDevice: uaUnknown,
},
{
name: "unrecognized client without a version exposes name only",
ua: "MysteryClient",
// No numeric version is parseable, so BrowserVersion is empty and the
// UI shows the bare name; OS/device fall back to Unknown.
wantBrowser: "MysteryClient",
wantBrowserVersion: "",
wantOS: uaUnknown,
wantOSVersion: "",
wantDevice: uaUnknown,
},
{
name: "Linux desktop shows no OS version (the parser reports an arch, not a version)",
ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
// mileusna returns OSVersion "x86_64" (CPU arch) for Linux; a non-digit
// value is dropped so the OS cell shows just "Linux", not "Linux x86_64".
wantBrowser: "Chrome",
wantBrowserVersion: "120.0.0.0",
wantOS: "Linux",
wantOSVersion: "",
wantDevice: "Desktop",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseUserAgent(tt.ua)
assert.Equal(t, tt.wantBrowser, got.Browser)
assert.Equal(t, tt.wantBrowserVersion, got.BrowserVersion)
assert.Equal(t, tt.wantOS, got.OS)
assert.Equal(t, tt.wantOSVersion, got.OSVersion)
assert.Equal(t, tt.wantDevice, got.Device)
})
}
}

// TestParseUserAgent_BoundsPathologicalValues guards the display layer against
// attacker-controlled User-Agents: an unrecognized, no-delimiter UA is surfaced
// verbatim as the "name" by the parser, and version tokens are equally
// unbounded. Both must be length-capped (and non-numeric versions dropped) so a
// single crafted session can't blow out the Active Sessions table.
func TestParseUserAgent_BoundsPathologicalValues(t *testing.T) {
got := ParseUserAgent(strings.Repeat("Z", 250))
assert.LessOrEqual(t, len([]rune(got.Browser)), maxLabelRunes+1, "name must be length-bounded")

// A non-numeric "version" (Linux/ChromeOS arch, markup, junk) is suppressed.
got = ParseUserAgent("Firefox/<b>x</b>")
assert.Equal(t, "Firefox", got.Browser)
assert.Empty(t, got.BrowserVersion, "non-numeric version must be dropped")

// A very long but digit-leading version is truncated, not shown in full.
got = ParseUserAgent("Weird/9" + strings.Repeat("9", 200))
assert.LessOrEqual(
t,
len([]rune(got.BrowserVersion)),
maxVersionRunes+1,
"version must be length-bounded",
)
}

// TestParseUserAgent_BotDoesNotPanic asserts a bot UA is classified without panicking.
func TestParseUserAgent_BotDoesNotPanic(t *testing.T) {
got := ParseUserAgent(
Expand All @@ -67,7 +130,7 @@ func TestParseUserAgent_BotDoesNotPanic(t *testing.T) {
assert.NotEmpty(t, got.OS)
}

// TestParseUserAgent_NeverEmpty guarantees every field is non-empty for any
// TestParseUserAgent_NeverEmpty guarantees every name field is non-empty for any
// input, since the display layer relies on that invariant.
func TestParseUserAgent_NeverEmpty(t *testing.T) {
for _, ua := range []string{"", "garbage", "x", "curl/8.4.0", "PostmanRuntime/7.36.0"} {
Expand Down
Loading
Loading