From e66252754aa60c8126f83439303359bfa3ad96a6 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:58:59 -0600 Subject: [PATCH 1/2] fix(redact): a login in a logged URL is masked lib.redact masked JSON members and key=value pairs but not a URL's userinfo, so rtsp://user:pass@host reached a log whole. The text path now masks the login up to the last @ before the host, as a browser reads it, and leaves an @ in a path, query or fragment alone. --- template/src/lib/redact.lua | 3 ++ template/test/test_http_redact.lua | 49 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/template/src/lib/redact.lua b/template/src/lib/redact.lua index 26dad5e..9f44c9d 100644 --- a/template/src/lib/redact.lua +++ b/template/src/lib/redact.lua @@ -147,6 +147,9 @@ local function redactSerialized(text) return key .. separator .. '"' .. REDACTED .. '"' end end) + -- A URL's login: rtsp://user:pass@host. As a browser reads it, the login runs to the last "@" before the + -- host ends at "/", "?" or "#", so a raw "@" in a password is masked whole and one in a query is left alone. + text = text:gsub('(://)[^/?#%s"]+@', "%1" .. REDACTED .. "@") return redactPairs(text) end diff --git a/template/test/test_http_redact.lua b/template/test/test_http_redact.lua index ec6793e..2ec6fe1 100644 --- a/template/test/test_http_redact.lua +++ b/template/test/test_http_redact.lua @@ -275,4 +275,53 @@ do T.check("form value masked through its slash", out == "password=" .. REDACTED .. "&next=/home", out) end +-------------------------------------------------------------------------------- +T.section("A URL's login is masked") +-------------------------------------------------------------------------------- +do + local out = redact("rtsp://viewer:CAM_PASS_LEAK@192.0.2.20:554/stream1") + T.check("user and password masked", out == "rtsp://" .. REDACTED .. "@192.0.2.20:554/stream1", out) + + out = redact("https://USER_LEAK@example.com/api") + T.check("a bare user masked", out == "https://" .. REDACTED .. "@example.com/api", out) + + out = R.text("rtsp://viewer:CAM_PASS_LEAK@192.0.2.20") + T.check("masked by redact.text, with no path", out == "rtsp://" .. REDACTED .. "@192.0.2.20", out) + + out = redact("rtsp://viewer:p@ss_LEAK@192.0.2.20/live") + T.check("a raw @ in the password masked whole", out == "rtsp://" .. REDACTED .. "@192.0.2.20/live", out) + + out = redact("rtsp://viewer:CAM_PASS_LEAK@192.0.2.20/live?token=QUERY_TOKEN_LEAK&channel=1") + T.check( + "a query token still masked beside the login", + out == "rtsp://" .. REDACTED .. "@192.0.2.20/live?token=" .. REDACTED .. "&channel=1", + out + ) + + out = redact('{"name":"Front Camera","stream":"rtsp://viewer:CAM_PASS_LEAK@192.0.2.20/live"}') + T.excludes("a login in JSON text masked", out, "CAM_PASS_LEAK") + T.contains("the JSON text's URL keeps its host", out, "rtsp://" .. REDACTED .. "@192.0.2.20/live") + T.contains("the JSON text's other members kept", out, "Front Camera") + + out = encoded(redact({ name = "Front Camera", streams = { "rtsp://viewer:CAM_PASS_LEAK@192.0.2.20/live" } })) + T.excludes("a login in a table leaf masked", out, "CAM_PASS_LEAK") + T.contains("the table leaf keeps its host", out, "rtsp://" .. REDACTED .. "@192.0.2.20/live") +end + +-------------------------------------------------------------------------------- +T.section("An @ that is not a login stays readable") +-------------------------------------------------------------------------------- +do + for _, text in ipairs({ + "https://example.com?email=user@example.com", + "https://example.com#user@example.com", + "https://example.com/users/user@example.com", + "GET https://example.com for user@example.com", + '{"url":"https://example.com","email":"user@example.com"}', + }) do + local out = R.text(text) + T.check("left alone: " .. text, out == text, out) + end +end + T.finish() From b66deded61a643bd5aec91fc4df7f8acb7e6abe9 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:29:28 -0600 Subject: [PATCH 2/2] test(redact): a raw ' or , in a URL's password is masked whole Both may stand raw in a login, so a class that stops at either prints the password. --- template/test/test_http_redact.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/template/test/test_http_redact.lua b/template/test/test_http_redact.lua index 2ec6fe1..86a2cff 100644 --- a/template/test/test_http_redact.lua +++ b/template/test/test_http_redact.lua @@ -291,6 +291,10 @@ do out = redact("rtsp://viewer:p@ss_LEAK@192.0.2.20/live") T.check("a raw @ in the password masked whole", out == "rtsp://" .. REDACTED .. "@192.0.2.20/live", out) + -- "'" and "," may stand raw in a login, so stopping at either would print this password whole. + out = redact("rtsp://viewer:it's,ok_LEAK@192.0.2.20/live") + T.check("a raw ' or , in the password masked whole", out == "rtsp://" .. REDACTED .. "@192.0.2.20/live", out) + out = redact("rtsp://viewer:CAM_PASS_LEAK@192.0.2.20/live?token=QUERY_TOKEN_LEAK&channel=1") T.check( "a query token still masked beside the login",