如何安全地从std::istream中读取一行?

55

我想要从一个 std::istream安全地读取一行。这个流可以是任何东西,比如一个 Web 服务器上的连接或者处理来自未知来源的文件的程序。有许多回答开始执行与此代码道德等效的操作:

void read(std::istream& in) {
    std::string line;
    if (std::getline(in, line)) {
        // process the line
    }
}

考虑到in的来源可能不可靠,使用上述代码会导致一个漏洞:恶意代理可以使用巨大的行对这个代码进行拒绝服务攻击。因此,我希望将行长度限制在某个相当高的值,比如4百万个char。虽然可能会遇到一些很长的行,但为每个文件分配缓冲区并使用std::istream::getline()是不可行的。

如何限制最大行大小,最好不要过分扭曲代码,也不要预先分配大块内存?


8
用一个自定义分配器,如果被要求分配超过阈值的内存则抛出异常。使用该分配器构造一个basic_string对象,并将内容读入其中。 - Praetorian
或许可以创建 std::string 的子类,并提供一个返回较小值的 max_size() 函数? - Collin
@Praetorian:我猜使用分配器可能是一个选项。不幸的是,它会改变std::string的类型。 - Dietmar Kühl
2
你可以用自己的实现替换in的streambuf,该实现包装了原始的streambuf,并在读取一定数量的字符时发送一个'\n' - jrok
1
@DietmarKühl:也许你可以尝试在提取前简单地检查缓冲区中的字符数:if (in.rdbuf()->in_avail() > max_size) { /* end */ }... - mb84
显示剩余4条评论
4个回答

37

你可以编写自己的 std::getline 版本,并带有一个最大读取字符数的参数,称为 getline_n 或其他名称。

#include <string>
#include <iostream>

template<typename CharT, typename Traits, typename Alloc>
auto getline_n(std::basic_istream<CharT, Traits>& in, std::basic_string<CharT, Traits, Alloc>& str, std::streamsize n) -> decltype(in) {
    std::ios_base::iostate state = std::ios_base::goodbit;
    bool extracted = false;
    const typename std::basic_istream<CharT, Traits>::sentry s(in, true);
    if(s) {
        try {
            str.erase();
            typename Traits::int_type ch = in.rdbuf()->sgetc();
            for(; ; ch = in.rdbuf()->snextc()) {
                if(Traits::eq_int_type(ch, Traits::eof())) {
                    // eof spotted, quit
                    state |= std::ios_base::eofbit;
                    break;
                }
                else if(str.size() == n) {
                    // maximum number of characters met, quit
                    extracted = true;
                    in.rdbuf()->sbumpc();
                    break;
                }
                else if(str.max_size() <= str.size()) {
                    // string too big
                    state |= std::ios_base::failbit;
                    break;
                }
                else {
                    // character valid
                    str += Traits::to_char_type(ch);
                    extracted = true;
                }
            }
        }
        catch(...) {
            in.setstate(std::ios_base::badbit);
        }
    }

    if(!extracted) {
        state |= std::ios_base::failbit;
    }

    in.setstate(state);
    return in;
}

int main() {
    std::string s;
    getline_n(std::cin, s, 10); // maximum of 10 characters
    std::cout << s << '\n';
}

可能有点过头了。


3
编写一个版本的 getline() 是一个选项(尤其是在过去,我已经实现了所有 IOStreams 库)。我不知道为什么没想到:也许我太专注于另外两个解决方案(其中只提到了一个)。 - Dietmar Kühl
我唯一质疑的是对reserve的调用,因为原帖中提到需要4MB作为保护,但实际上字符串大小可能要小得多。让用户自己执行reserve可能更好。 - Dave S
有趣的是,我最初撰写此代码时并没有包括 reserve 调用,但我为了保险起见后来将其添加进去了。如果由我决定的话,我也不会这么做。我想我应该将其删除。 - Rapptz
我有点困惑——这段代码实际上在哪里检查换行符? - Siler

18

已经有一个作为istream成员函数的getline函数了,您只需要对其进行缓冲区管理包装。

#include <assert.h>
#include <istream>
#include <stddef.h>         // ptrdiff_t
#include <string>           // std::string, std::char_traits

typedef ptrdiff_t Size;

namespace my {
    using std::istream;
    using std::string;
    using std::char_traits;

    istream& getline(
        istream& stream, string& s, Size const buf_size, char const delimiter = '\n'
        )
    {
        s.resize( buf_size );  assert( s.size() > 1 );
        stream.getline( &s[0], buf_size, delimiter );
        if( !stream.fail() )
        {
            Size const n = char_traits<char>::length( &s[0] );
            s.resize( n );      // Downsizing.
        }
        return stream;
    }
}  // namespace my

1
“&s[0]” 让我感到不安。 - Inverse
4
@Inverse: 没有什么需要感到不安。你同样可以感到不安于除法,理由是它可能会变成0. 在这个代码中,对值的相关限制(字符串长度必须大于0)通过“assert”表达出来,这通常是一个良好的实践方式,使得代码比没有“assert”更加安全。使用“assert”,我们必须辛勤工作才能避免未定义行为的问题,即:使用无效的“buf_size”参数调用函数,并且定义了“NDEBUG”以抑制“assert”。这就是为什么你应该使用“assert”的原因。 - Cheers and hth. - Alf
我的意思是,我理解std::string中的数据不保证是连续的,只有.c_str()是。因此,在std::vector中使用&v[0]是可以的,但在std::string中则不完全适用。 - Inverse
C++11中的COW SO线程在这里 - M.M
@MattMcNabb:Luc Danton的回答解释了COW在C++11中至少部分被禁止的原因,这是因为要求复制构造函数进行缓冲区复制。 - Cheers and hth. - Alf
显示剩余5条评论

8

通过创建一个 std::istream::getline 的包装器来替换 std::getline

std::istream& my::getline( std::istream& is, std::streamsize n, std::string& str, char delim )
    {
    try
       {
       str.resize(n);
       is.getline(&str[0],n,delim);
       str.resize(is.gcount());
       return is;
       }
    catch(...) { str.resize(0); throw; }
    }

如果你想避免过多的临时内存分配,可以使用一个循环来按需增加分配的内存空间(每次可能会翻倍增长)。不要忘记istream对象上可能启用或禁用异常。

这里是使用更高效分配策略的版本:

std::istream& my::getline( std::istream& is, std::streamsize n, std::string& str, char delim )
    {
    std::streamsize base=0;
    do {
       try
          {
          is.clear();
          std::streamsize chunk=std::min(n-base,std::max(static_cast<std::streamsize>(2),base));
          if ( chunk == 0 ) break;
          str.resize(base+chunk);
          is.getline(&str[base],chunk,delim);
          }
       catch( std::ios_base::failure ) { if ( !is.gcount () ) str.resize(0), throw; }
       base += is.gcount();
       } while ( is.fail() && is.gcount() );
    str.resize(base);
    return is;
    }

1
实现时,这两种方法都会在生成的字符串中留下终止符'\0'。这对于C++字符串来说并不正常,因此改进的方法是在返回之前弹出最后一个字符。请注意,基于扫描'\0'字符串进行调整大小可能会被认为是有缺陷的,因为'\0'可能是字符串中的有效字符(这不是C语言)。另外,我不知道这会如何与Microsoft“文本”模式互动,其中文本行通常由两个字符终止。如果我理解文档正确的话,因为is.getline()是“未格式化”的,所以'\r'将留在字符串中。 - Brent Bradburn
如果(!str.empty()),则 str.resize(str.size()-1)。 - Brent Bradburn
try { my::getline( is, 4096, s, '\n' ); } catch ( std::ios::failure const & ) {} - Brent Bradburn

5
根据评论和答案,似乎有三种方法:
  1. 编写自定义版本的getline(),可能在内部使用std::istream::getline()成员获取实际字符。
  2. 使用过滤流缓冲区来限制可能接收的数据量。
  3. 不是读取std::string,而是使用具有自定义分配器的字符串实例化,限制存储在字符串中的内存量。
并非所有建议都带有代码。此答案为所有方法提供代码,并略微讨论了这三种方法。在进入实现细节之前,首先值得指出的是,如果收到的输入过长,则有多种选择:1.读取过长的行可能导致成功读取部分行,即结果字符串包含读取内容并且流没有设置任何错误标志。然而,这意味着无法区分一条正好达到限制或太长的行。由于限制本身有点任意,因此可能并不重要。2.读取过长的行可能被视为失败(即设置std::ios_base::failbit和/或std::ios_base::bad_bit),并且由于读取失败,产生一个空字符串。显然,产生一个空字符串可以防止潜在地查看迄今为止已读取的字符串,以可能看到正在发生的事情。3.读取过长的行可以提供部分行读取,并在流上设置错误标志。这似乎是合理的行为,既检测到有问题,也提供了输入以供检查。虽然已经有多个代码示例实现了一个有限版本的getline(),但这里又有另一个!我认为它更简单(尽管可能更慢;当必要时可以处理性能),并保留了std::getline()的接口:它使用流的width()来通信限制(考虑到width()是对std::getline()的合理扩展)。
template <typename cT, typename Traits, typename Alloc>
std::basic_istream<cT, Traits>&
safe_getline(std::basic_istream<cT, Traits>& in,
             std::basic_string<cT, Traits, Alloc>& value,
             cT delim)
{
    typedef std::basic_string<cT, Traits, Alloc> string_type;
    typedef typename string_type::size_type size_type;

    typename std::basic_istream<cT, Traits>::sentry cerberos(in);
    if (cerberos) {
        value.clear();
        size_type width(in.width(0));
        if (width == 0) {
            width = std::numeric_limits<size_type>::max();
        }
        std::istreambuf_iterator<char> it(in), end;
        for (; value.size() != width && it != end; ++it) {
            if (!Traits::eq(delim, *it)) {
                value.push_back(*it);
            }
            else {
                ++it;
                break;
            }
        }
        if (value.size() == width) {
            in.setstate(std::ios_base::failbit);
        }
    }
    return in;
}

这个版本的getline()std::getline()使用方式相同,但当需要限制读取的数据量时,可以设置width(),例如:

std::string line;
if (safe_getline(in >> std::setw(max_characters), line)) {
    // do something with the input
}

另一种方法是使用过滤流缓冲器来限制输入的数量:该过滤器仅计算处理的字符数并将其限制为适当数量的字符。实际上,这种方法更容易应用于整个流而不是单个行:在处理一个行时,过滤器无法从底层流获取完整的缓冲区,因为没有可靠的方式将字符放回去。实现非缓冲版本仍然很简单,但可能不太高效。
template <typename cT, typename Traits = std::char_traits<char> >
class basic_limitbuf
    : std::basic_streambuf <cT, Traits> {
public:
    typedef Traits                    traits_type;
    typedef typename Traits::int_type int_type;

private:
    std::streamsize                   size;
    std::streamsize                   max;
    std::basic_istream<cT, Traits>*   stream;
    std::basic_streambuf<cT, Traits>* sbuf;

    int_type underflow() {
        if (this->size < this->max) {
            return this->sbuf->sgetc();
        }
        else {
            this->stream->setstate(std::ios_base::failbit);
            return traits_type::eof();
        }
    }
    int_type uflow()     {
        if (this->size < this->max) {
            ++this->size;
            return this->sbuf->sbumpc();
        }
        else {
            this->stream->setstate(std::ios_base::failbit);
            return traits_type::eof();
        }
    }
public:
    basic_limitbuf(std::streamsize max,
                   std::basic_istream<cT, Traits>& stream)
        : size()
        , max(max)
        , stream(&stream)
        , sbuf(this->stream->rdbuf(this)) {
    }
    ~basic_limitbuf() {
        std::ios_base::iostate state = this->stream->rdstate();
        this->stream->rdbuf(this->sbuf);
        this->stream->setstate(state);
    }
};

这个流缓冲区已经设置好,可以在构造时插入自身,在销毁时删除自身。也就是说,它可以简单地像这样使用:

std::string line;
basic_limitbuf<char> sbuf(max_characters, in);
if (std::getline(in, line)) {
    // do something with the input
}

很容易添加一个设置限制的操纵器。这种方法的一个优点是,如果流的总大小可以被限制,则不需要触及任何读取代码:过滤器可以在创建流后立即设置。当不需要回退过滤器时,过滤器还可以使用缓冲区,这将极大地提高性能。
第三种建议的方法是使用具有自定义分配器的std::basic_string。关于分配器的方法有两个有点尴尬的方面:
  1. 正在读取的字符串实际上具有无法立即转换为std::string的类型(尽管进行转换也不难)。
  2. 最大数组大小可以很容易地受到限制,但该字符串将具有一些更小或多或少随机的大小:当流无法分配时,会抛出异常,并且不会尝试通过较小的大小来增加字符串。
以下是限制分配大小所需的代码:
template <typename T>
struct limit_alloc
{
private:
    std::size_t max_;
public:
    typedef T value_type;
    limit_alloc(std::size_t max): max_(max) {}
    template <typename S>
    limit_alloc(limit_alloc<S> const& other): max_(other.max()) {}
    std::size_t max() const { return this->max_; }
    T* allocate(std::size_t size) {
        return size <= max_
            ? static_cast<T*>(operator new[](size))
            : throw std::bad_alloc();
    }
    void  deallocate(void* ptr, std::size_t) {
        return operator delete[](ptr);
    }
};

template <typename T0, typename T1>
bool operator== (limit_alloc<T0> const& a0, limit_alloc<T1> const& a1) {
    return a0.max() == a1.max();
}
template <typename T0, typename T1>
bool operator!= (limit_alloc<T0> const& a0, limit_alloc<T1> const& a1) {
    return !(a0 == a1);
}

分配器将类似于以下用法(代码在最新版本的 clang 下编译正常,但在 gcc 下不行):

std::basic_string<char, std::char_traits<char>, limit_alloc<char> >
    tmp(limit_alloc<char>(max_chars));
if (std::getline(in, tmp)) {
    std::string(tmp.begin(), tmp.end());
    // do something with the input
}

总之,有多种方法可以限制基于过长行的拒绝服务攻击,每种方法都有自己的小缺点,但对于所述目标而言都是合理可行的:

  1. 使用自定义版本的 getline() 需要更改读取代码。
  2. 使用自定义流缓冲区,除非整个流的大小可以受限,否则会变慢。
  3. 使用自定义分配器给予更少的控制,并需要对读取代码进行一些更改。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接