Skip to content

CWE-197: Numeric Truncation Error - C++

Overview

C++ inherits C's silent narrowing conversions, so the same bit-loss applies to int64_t-to-int32_t, double-to-int, and similar. C++11 added one real improvement: brace initialization (int x{value};) is a compile error if value would narrow, which makes a subset of these conversions impossible to write by accident - though it reports where the narrowing is written rather than whether the value fits. Everywhere else - an explicit static_cast, a function call, a regular = assignment - narrowing is exactly as silent as it is in C, so an explicit, validated conversion is still needed.

Common Vulnerable Patterns

Unchecked static_cast Narrowing

#include <cstdint>
#include <memory>

// VULNERABLE - static_cast narrows silently, no range check
void allocate_buffer(int64_t size) {
    int32_t buffer_size = static_cast<int32_t>(size);   // narrows silently
    auto buffer = std::make_unique<char[]>(buffer_size);
}

Why this is vulnerable: static_cast documents that a conversion is intentional, but it performs no range check - a size whose value doesn't fit in int32_t is truncated exactly as a C-style cast would truncate it.

Narrowing That Brace Initialization Would Have Caught, Bypassed by Using =

#include <cstddef>
#include <cstdint>

// VULNERABLE - plain assignment narrows silently; brace-init would have caught this
void resize_from_length(std::size_t length) {
    int32_t count = length;   // ordinary assignment - narrows silently if length is large
    // ... use count
}

Why this is vulnerable: int32_t count{length}; would be a compile error here if length's type could hold a value count can't represent. Writing = length instead of {length} opts back into C's silent narrowing behavior.

Secure Patterns

safe_narrow_cast Template

#include <concepts>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <stdexcept>
#include <utility>

template <std::integral Target, std::integral Source>
Target safe_narrow_cast(Source value) {
    if (!std::in_range<Target>(value)) {
        throw std::overflow_error("Numeric truncation");
    }
    return static_cast<Target>(value);
}

void allocate_buffer(int64_t size) {
    const auto buffer_size = safe_narrow_cast<int32_t>(size);
    if (buffer_size <= 0) {
        throw std::invalid_argument("Invalid buffer size");
    }
    auto buffer = std::make_unique<char[]>(static_cast<std::size_t>(buffer_size));
}

Why this works: std::in_range<Target>(value) (C++20, <utility>) asks whether the value is representable in Target by comparing mathematical values, which is the only form of the check that survives a change of signedness. The obvious hand-written version does not:

// BROKEN - the guard performs the conversion it exists to prevent
if (value < std::numeric_limits<Target>::min() ||
    value > std::numeric_limits<Target>::max()) { /* reject */ }

With an unsigned Source and a signed Target, std::numeric_limits<int32_t>::min() converts to std::uint64_t before the comparison and becomes 18446744071562067968, so safe_narrow_cast<int32_t>(std::uint64_t{5}) rejects a value that fits comfortably - and the mirror arrangement accepts values that do not. It is also precisely what -Wsign-compare reports, so the version above fails the -Werror build this page recommends.

Constraining both parameters with std::integral is deliberate. A double source needs a different check - NaN and infinity have to be rejected first, and std::numeric_limits<std::int64_t>::max() is not exactly representable as a double, so a value > max() test lets the boundary case through into an undefined conversion. Keeping floating point out of this template forces that case to be written separately rather than silently mishandled here. Before C++20, the equivalent is a hand-written check that tests the sign first and only then compares magnitudes through a common wide type.

Brace Initialization to Turn Narrowing Into a Compile Error

#include <cstddef>
#include <cstdint>

void process_small_count(int32_t small_count);

void from_size(std::size_t length) {
    // int32_t count = length;   // compiles, and narrows silently
    // int32_t count{length};    // ill-formed: brace-init rejects the narrowing

    const int32_t count = safe_narrow_cast<int32_t>(length);   // what the error asks for
    process_small_count(count);
}

Why this works: list-initialization ({}) makes a narrowing conversion ill-formed, so the compiler names the file and line where a size_t is being squeezed into an int32_t. That is its whole contribution, and it is worth having - the alternative is a plain = that compiles and says nothing - but it locates the conversion rather than validating it, so the error has to be answered with a checked conversion.

The wrong way to answer it is int32_t count{static_cast<int32_t>(length)}. The cast makes the types match, so there is no longer a narrowing conversion for brace-init to reject, the diagnostic disappears, and the truncation it was reporting happens exactly as before - now with a cast beside it implying somebody checked. Common Pitfalls below names this; it is a common response to the error, because the error message points at the braces rather than at the value.

Considerations

Decide whether the narrowing is provably safe for this input domain. A conversion is fine when the source is already bounded and that bound holds for every caller, not just the one in front of you. File sizes, lengths reported by a remote peer, and aggregated totals are the cases where it usually does not.

How the failure is reported is a codebase-wide decision, not a local one. Throwing on an out-of-range conversion is the cleaner default, but plenty of codebases build with -fno-exceptions - embedded targets, game engines, and anything with a hard real-time budget. There, a checked helper returning bool with an out-parameter is the equivalent. Pick whichever matches the surrounding code and use it consistently; a throwing helper dropped into a no-exceptions build fails at link time or terminates at runtime.

Testing

  • Unit-test safe_narrow_cast (or your project's equivalent) with the target type's min(), max(), one past each boundary, and a value whose truncated low bits would look valid.
  • Confirm the exception-throwing path is actually exercised in tests, not just the success path.
  • Compile with -Wconversion -Wnarrowing -Werror (or /W4 /WX, which raises C4244 for a narrowing integer conversion and C4267 for a size_t narrowed to a smaller type - both on by default at that level) and confirm the build is clean.
  • Run Clang-Tidy's bugprone-narrowing-conversions check or an equivalent static analyzer across the codebase, not just new code.

Common Pitfalls

  • Using static_cast and treating it as validation: static_cast states intent, it does not check the value - it truncates exactly as silently as a C-style cast.
  • Catching std::overflow_error and substituting a default value: this reintroduces the original problem under a different name - the truncated (now default) value still flows into whatever used the size or count.
  • Relying on brace-init narrowing errors as the only defense: they only fire when the compiler can prove narrowing at compile time from a constant or a type mismatch it tracks; a static_cast inside the braces defeats the check, and a value narrowed through an intermediate variable may not trigger it either.
  • Templating safe_narrow_cast but forgetting the <= 0 / semantic check: a value can be perfectly representable in the target type and still be nonsensical for its use (a negative buffer size) - range-fitting and business-logic validity are two separate checks.

Additional Resources