Skip to main content

#
jellytau_lib/utils/
diagnostics.rs

    1//! Credential redaction and crash capture for diagnostic logs.
    2//!
    3//! TRACES: UR-078 | DR-218
    4//!
    5//! ## Why redaction lives here and not at the export
    6//!
    7//! A diagnostic bundle is something a user attaches to a public bug report. If a
    8//! Jellyfin access token can reach it, this feature is a credential-disclosure
    9//! bug with a friendly button on it.
    10//!
    11//! So [`redact`] runs in the log *formatter* — the token never reaches disk —
    12//! and again over every line the exporter copies, which covers files written by
    13//! an older build that lacked the formatter pass. Redacting only at export would
    14//! leave the secret sitting in a file on the device, which is exactly the thing
    15//! we are trying not to do.
    16//!
    17//! ## What is deliberately NOT redacted
    18//!
    19//! Server host, item ids, filenames and paths inside the app's own directories
    20//! all stay. They are not secrets and they are the entire diagnostic value of a
    21//! log: a bundle scrubbed of them is one nobody can debug anything from.
    22
    23use std::borrow::Cow;
    24
    25/// Replacement for a redacted value.
    26pub const REDACTED: &str = "[REDACTED]";
    27
    28/// Query-string parameters whose value is a credential.
    29///
    30/// Jellyfin accepts the API key under several spellings depending on the
    31/// endpoint and client generation, and this codebase has emitted more than one
    32/// of them over time.
    33const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
    34
    35/// Header names whose value is a credential.
    36const SECRET_HEADERS: &[&str] = &[
    37    "x-emby-token",
    38    "x-mediabrowser-token",
    39    "authorization",
    40    "x-emby-authorization",
    41];
    42
    43/// JSON keys whose value is a credential.
    44const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
    45
    46/// Strip credentials from one log line.
    47///
    48/// Idempotent: redacting an already-redacted line changes nothing, which matters
    49/// because the exporter may re-process a file the formatter already cleaned.
    50pub fn redact(line: &str) -> String {
    51    let mut out = redact_query_params(line);
    52    out = redact_headers(&out);
    53    out = redact_json_values(&out);
    54    out = redact_emby_auth(&out);
    55    out
    56}
    57
    58/// `?api_key=abc&x=1` -> `?api_key=[REDACTED]&x=1`
    59///
    60/// The value ends at the first character that cannot be part of one: `&`
    61/// separates parameters, and whitespace/quotes mean the URL itself ended.
    62fn redact_query_params(line: &str) -> String {
    63    let mut result = String::with_capacity(line.len());
    64    let lower = line.to_ascii_lowercase();
    65    let bytes = line.as_bytes();
    66    let mut i = 0;
    67
    68    while i < bytes.len() {
    69        let mut matched = None;
    70        for key in SECRET_QUERY_KEYS {
    71            // A key only counts when it is preceded by ? or & (or starts the
    72            // line), so a *word* like "token" inside prose is left alone.
    73            if lower[i..].starts_with(key) {
    74                let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
    75                let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
    76                let after = i + key.len();
    77                if is_param_start && after < bytes.len() && bytes[after] == b'=' {
    78                    matched = Some((*key, after + 1));
    79                    break;
    80                }
    81            }
    82        }
    83
    84        match matched {
    85            Some((key, value_start)) => {
    86                result.push_str(&line[i..i + key.len()]);
    87                result.push('=');
    88                result.push_str(REDACTED);
    89                let mut end = value_start;
    90                while end < bytes.len()
    91                    && !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
    92                {
    93                    end += 1;
    94                }
    95                i = end;
    96            }
    97            None => {
    98                // Advance one whole char, not one byte: a UTF-8 boundary split
    99                // would panic on the slice above.
    100                let ch = line[i..].chars().next().unwrap_or('\0');
    101                result.push(ch);
    102                i += ch.len_utf8();
    103            }
    104        }
    105    }
    106
    107    result
    108}
    109
    110/// `X-Emby-Token: abc` -> `X-Emby-Token: [REDACTED]`
    111///
    112/// Scans forward from a cursor rather than recursing on the rewritten string.
    113/// The obvious recursive version does not terminate: the replacement keeps the
    114/// header *name*, so the next call finds the same header again and recurses
    115/// until the stack is gone. A test provoked exactly that.
    116fn redact_headers(line: &str) -> String {
    117    let mut out = String::with_capacity(line.len());
    118    let mut rest = line;
    119
    120    'outer: loop {
    121        let lower = rest.to_ascii_lowercase();
    122
    123        // Earliest header match in what remains, so several headers on one line
    124        // are handled left to right.
    125        let mut best: Option<(usize, usize)> = None;
    126        for header in SECRET_HEADERS {
    127            let needle = format!("{header}:");
    128            if let Some(pos) = lower.find(&needle) {
    129                let candidate = (pos, needle.len());
    130                if best.is_none_or(|(best_pos, _)| pos < best_pos) {
    131                    best = Some(candidate);
    132                }
    133            }
    134        }
    135
    136        let Some((pos, needle_len)) = best else {
    137            break 'outer;
    138        };
    139
    140        let value_start = pos + needle_len;
    141        // The value runs to the next comma or the end of the line: reqwest's
    142        // debug output prints several headers comma-separated on one line.
    143        let value_end = rest[value_start..]
    144            .find(',')
    145            .map_or(rest.len(), |c| value_start + c);
    146
    147        out.push_str(&rest[..value_start]);
    148        out.push(' ');
    149        out.push_str(REDACTED);
    150
    151        // Continue strictly *after* the value just handled -- this is what makes
    152        // the loop terminate.
    153        rest = &rest[value_end..];
    154    }
    155
    156    out.push_str(rest);
    157    out
    158}
    159
    160/// `"AccessToken":"abc"` -> `"AccessToken":"[REDACTED]"`
    161fn redact_json_values(line: &str) -> String {
    162    let mut out = Cow::Borrowed(line);
    163
    164    for key in SECRET_JSON_KEYS {
    165        loop {
    166            let lower = out.to_ascii_lowercase();
    167            let pattern = format!("\"{key}\"");
    168            let Some(key_pos) = lower.find(&pattern) else {
    169                break;
    170            };
    171
    172            // Find the opening quote of the value after the colon.
    173            let after_key = key_pos + pattern.len();
    174            let Some(colon_rel) = out[after_key..].find(':') else {
    175                break;
    176            };
    177            let value_region = after_key + colon_rel + 1;
    178            let Some(open_rel) = out[value_region..].find('"') else {
    179                break;
    180            };
    181            let open = value_region + open_rel;
    182            let Some(close_rel) = out[open + 1..].find('"') else {
    183                break;
    184            };
    185            let close = open + 1 + close_rel;
    186
    187            // Already redacted: stop, or this loops forever.
    188            if &out[open + 1..close] == REDACTED {
    189                break;
    190            }
    191
    192            let mut replaced = String::with_capacity(out.len());
    193            replaced.push_str(&out[..open + 1]);
    194            replaced.push_str(REDACTED);
    195            replaced.push_str(&out[close..]);
    196            out = Cow::Owned(replaced);
    197        }
    198    }
    199
    200    out.into_owned()
    201}
    202
    203/// `MediaBrowser Token="abc"` -> `MediaBrowser Token="[REDACTED]"`
    204///
    205/// Jellyfin's own auth header format, which is not JSON and not a query param.
    206fn redact_emby_auth(line: &str) -> String {
    207    let lower = line.to_ascii_lowercase();
    208    let Some(pos) = lower.find("token=\"") else {
    209        return line.to_string();
    210    };
    211    let open = pos + "token=\"".len();
    212    let Some(close_rel) = line[open..].find('"') else {
    213        return line.to_string();
    214    };
    215    let close = open + close_rel;
    216    if &line[open..close] == REDACTED {
    217        return line.to_string();
    218    }
    219    let mut out = String::with_capacity(line.len());
    220    out.push_str(&line[..open]);
    221    out.push_str(REDACTED);
    222    out.push_str(&line[close..]);
    223    out
    224}
    225
    226/// Reduce a server URL to scheme and host.
    227///
    228/// The host is diagnostic (is it https? a LAN address? a reverse proxy?); the
    229/// path and any query on it are not, and a configured URL has been seen to carry
    230/// a token.
    231pub fn redact_server_url(url: &str) -> String {
    232    let Some(scheme_end) = url.find("://") else {
    233        return REDACTED.to_string();
    234    };
    235    let after_scheme = scheme_end + 3;
    236    let host_end = url[after_scheme..]
    237        .find('/')
    238        .map_or(url.len(), |slash| after_scheme + slash);
    239    // Credentials embedded as user:pass@host must not survive.
    240    let host = &url[after_scheme..host_end];
    241    let host = host.rsplit('@').next().unwrap_or(host);
    242    format!("{}://{}", &url[..scheme_end], host)
    243}
    244
    245/// Install a panic hook that records the panic through `log::error!` before the
    246/// default hook runs.
    247///
    248/// # Why it chains rather than replaces
    249///
    250/// `utils::lock` installs a silencing hook around its own tests, which
    251/// deliberately provoke poisoned locks. Replacing the current hook here would
    252/// make that test output scream about panics it is intentionally causing — and,
    253/// more importantly, replacing whatever hook is present is how you lose the
    254/// backtrace the runtime would otherwise print.
    255pub fn install_panic_hook() {
    256    let previous = std::panic::take_hook();
    257    std::panic::set_hook(Box::new(move |info| {
    258        // The payload is very often the formatted message of a `panic!`, so it
    259        // goes through redaction like any other line: a panic inside the HTTP
    260        // layer can carry a URL.
    261        let payload = panic_payload_string(info);
    262        let location = info
    263            .location()
    264            .map(|l| format!("{}:{}", l.file(), l.line()))
    265            .unwrap_or_else(|| "unknown location".to_string());
    266
    267        log::error!("PANIC at {location}: {}", redact(&payload));
    268        log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
    269
    270        previous(info);
    271    }));
    272}
    273
    274/// Extract a printable message from a panic payload.
    275fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
    276    let payload = info.payload();
    277    if let Some(s) = payload.downcast_ref::<&str>() {
    278        (*s).to_string()
    279    } else if let Some(s) = payload.downcast_ref::<String>() {
    280        s.clone()
    281    } else {
    282        "non-string panic payload".to_string()
    283    }
    284}
    285
    286#[cfg(test)]
    287mod tests {
    288    use super::*;
    289
    290    #[test]
    291    fn redacts_api_key_query_parameter() {
    292        let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
    293        let out = redact(line);
    294        assert!(!out.contains("abc123def"), "token survived: {out}");
    295        assert!(out.contains("api_key=[REDACTED]"));
    296        // The rest of the URL is what makes the line worth keeping.
    297        assert!(out.contains("media.example.com"));
    298        assert!(out.contains("Limit=50"));
    299    }
    300
    301    #[test]
    302    fn redacts_every_spelling_of_the_key_parameter() {
    303        for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
    304            let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
    305            let out = redact(&line);
    306            assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
    307            assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
    308        }
    309    }
    310
    311    #[test]
    312    fn redacts_auth_headers() {
    313        let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
    314        assert!(!out.contains("abc123"), "{out}");
    315        // A following header must survive -- the value stops at the comma.
    316        assert!(out.contains("Accept: application/json"), "{out}");
    317    }
    318
    319    #[test]
    320    fn redacts_authorization_header() {
    321        let out = redact("Authorization: Bearer verysecrettoken");
    322        assert!(!out.contains("verysecrettoken"), "{out}");
    323    }
    324
    325    #[test]
    326    fn redacts_json_access_token() {
    327        let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
    328        assert!(!out.contains("abc123"), "{out}");
    329        // The username is not a credential and is diagnostic.
    330        assert!(out.contains("duncan"), "{out}");
    331    }
    332
    333    #[test]
    334    fn redacts_the_emby_auth_header_form() {
    335        let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
    336        let out = redact(line);
    337        assert!(!out.contains("abc123xyz"), "{out}");
    338        assert!(out.contains("JellyTau"), "{out}");
    339    }
    340
    341    #[test]
    342    fn is_idempotent() {
    343        // The exporter re-processes files the formatter already cleaned; a
    344        // second pass must not corrupt them or loop.
    345        let once = redact("https://h/Items?api_key=abc&z=1");
    346        let twice = redact(&once);
    347        assert_eq!(once, twice);
    348    }
    349
    350    #[test]
    351    fn leaves_ordinary_lines_untouched() {
    352        let line = "player: advancing to next episode (item 4f2a, position 0)";
    353        assert_eq!(redact(line), line);
    354    }
    355
    356    #[test]
    357    fn does_not_redact_the_word_token_in_prose() {
    358        // "token" appears in comments and messages constantly. Only a real
    359        // parameter or header assignment should trigger.
    360        let line = "refreshing the access token because the session expired";
    361        assert_eq!(redact(line), line);
    362    }
    363
    364    #[test]
    365    fn handles_multibyte_characters_without_panicking() {
    366        // The scanner walks bytes; a naive implementation slices mid-character.
    367        let line = "playing “Où est le café” from https://h/Items?api_key=abc";
    368        let out = redact(line);
    369        assert!(!out.contains("abc"), "{out}");
    370        assert!(out.contains("café"), "{out}");
    371    }
    372
    373    #[test]
    374    fn server_url_keeps_scheme_and_host_only() {
    375        assert_eq!(
    376            redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
    377            "https://media.example.com"
    378        );
    379        assert_eq!(
    380            redact_server_url("http://192.168.1.10:8096/"),
    381            "http://192.168.1.10:8096"
    382        );
    383    }
    384
    385    #[test]
    386    fn server_url_drops_embedded_credentials() {
    387        // http://user:password@host is a valid URL and has been pasted into
    388        // server-address fields before.
    389        assert_eq!(
    390            redact_server_url("https://duncan:hunter2@media.example.com/"),
    391            "https://media.example.com"
    392        );
    393    }
    394
    395    #[test]
    396    fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
    397        assert_eq!(redact_server_url("media.example.com"), REDACTED);
    398    }
    399}