Skip to content

parse_hex panics on non-ASCII input like "#éa" / "#€abc" #228

@meng-xu-cs

Description

@meng-xu-cs

Summary

parse_hex currently uses s.len() and direct &str slicing (&s[0..1], &s[0..2], etc.). Because str::len() is measured in bytes, not characters, non-ASCII input whose UTF-8 byte length happens to be 3 or 6 can enter the short/long hex branches and then panic when a slice boundary falls inside a multi-byte character.

This is reachable through the public string-based APIs such as .color(...), .on_color(...), and Color::from_str.

Reproduction

use colored::Colorize;

fn main() {
    // Panics in parse_hex(): len("éa") == 3 bytes, then &s[0..1] is not a char boundary.
    let _ = "hello".color("#éa");

    // Also panics: len("€abc") == 6 bytes, then &s[0..2] is not a char boundary.
    let _ = "hello".on_color("#€abc");
}

A smaller reproduction through parsing also triggers it:

use colored::Color;

fn main() {
    let _: Result<Color, _> = "#éa".parse();
}

Expected behavior

Invalid hex strings should be rejected without panicking.

Specifically:

  • "#éa" / "#€abc" should return Err(()) from FromStr.
  • Higher-level conversions that currently fall back on invalid input should continue to do so instead of unwinding.

Actual behavior

The crate panics at runtime with a byte index ... is not a char boundary error while slicing the &str inside parse_hex.

Why this happens

parse_hex treats these conditions as equivalent, but they are not for UTF-8 strings:

  • s.len() == 3 / s.len() == 6
  • s contains 3 / 6 ASCII hex characters

Examples:

  • "éa" is 2 characters but 3 bytes in UTF-8.
  • "€abc" is 4 characters but 6 bytes in UTF-8.

That means the function can enter a hex-parsing branch even though the string is not actually made of 3 or 6 ASCII hex digits.

Impact

This is a denial-of-service style bug for applications that pass user-controlled strings into the public color parsing APIs. Instead of treating malformed input as invalid, the process can unwind and crash.

Suggested fix

Reject non-ASCII / non-hex input before doing any byte indexing, or avoid &str slicing entirely.

One minimal fix would be:

fn parse_hex(s: &str) -> Option<Color> {
    if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }

    if s.len() == 6 {
        let r = u8::from_str_radix(&s[0..2], 16).ok()?;
        let g = u8::from_str_radix(&s[2..4], 16).ok()?;
        let b = u8::from_str_radix(&s[4..6], 16).ok()?;
        Some(Color::TrueColor { r, g, b })
    } else if s.len() == 3 {
        let r = u8::from_str_radix(&s[0..1], 16).ok()?;
        let r = r | (r << 4);
        let g = u8::from_str_radix(&s[1..2], 16).ok()?;
        let g = g | (g << 4);
        let b = u8::from_str_radix(&s[2..3], 16).ok()?;
        let b = b | (b << 4);
        Some(Color::TrueColor { r, g, b })
    } else {
        None
    }
}

Since is_ascii_hexdigit() guarantees ASCII-only input, the existing byte slicing becomes safe again.

Another option would be to parse via as_bytes() and avoid &str slicing altogether.

Suggested tests

It would be good to add regression tests for at least:

assert_eq!(Color::from("#éa"), Color::White);
assert_eq!(Color::from("#€abc"), Color::White);
assert_eq!("#éa".parse::<Color>(), Err(()));
assert_eq!("#€abc".parse::<Color>(), Err(()));

And optionally verify that .color("#éa") / .on_color("#€abc") do not panic.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions