diff --git a/Age.Tests/UnitTests.cs b/Age.Tests/UnitTests.cs index 17ece00..7039aa6 100644 --- a/Age.Tests/UnitTests.cs +++ b/Age.Tests/UnitTests.cs @@ -552,6 +552,87 @@ public void Reject_Empty_Body_Lines() Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); }); } + [Fact] + public void Reject_Oversized_Armor_Line() + { + // A single body line far longer than any legal armor line must be rejected + // without buffering the whole (potentially unbounded) line into memory. + var text = "-----BEGIN AGE ENCRYPTED FILE-----\n" + new string('A', AgeLimits.MaxArmorLineBytes + 1000) + "\n"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + var ex = Assert.Throws(() => { using var s = AsciiArmor.Dearmor(stream); ReadAllBytes(s); }); + Assert.Contains("exceeds", ex.Message); + } + + [Fact] + public void Reject_Oversized_Line_Before_Begin_Marker() + { + // The bound must also protect the marker search before the armor body. + var text = new string('x', AgeLimits.MaxArmorLineBytes + 1000); + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + Assert.Throws(() => AsciiArmor.Dearmor(stream)); + } + + [Fact] + public void Reject_Oversized_Header_Line() + { + // A header line with no newline must be bounded before authentication. + var text = "age-encryption.org/v1\n" + new string('a', 100_000) + "\n"; + using var stream = new MemoryStream(Encoding.ASCII.GetBytes(text)); + var ex = Assert.Throws(() => AgeHeader.Parse(stream)); + Assert.Contains("exceeds", ex.Message); + } + + [Fact] + public void Reject_Oversized_Header_Total() + { + // Many individually-short lines — none tripping the per-line cap — must + // still be bounded in total, so a header padded with a huge number of + // small (structurally valid) stanzas can't exhaust memory before the MAC + // is checked. Drive HeaderReader directly with an endless run of short + // lines: the total-bytes cap must stop it well before it reads forever. + using var stream = new RepeatingLineStream(lineLength: 64); + var reader = new HeaderReader(stream); + var ex = Assert.Throws(() => + { + while (reader.ReadLine() is not null) { } + }); + Assert.Contains("exceeds", ex.Message); + } + + // Emits an unbounded sequence of identical short lines ("xxx…\n"), each below + // the per-line cap, so only the total-header cap can terminate a read loop. + private sealed class RepeatingLineStream(int lineLength) : Stream + { + private readonly byte[] _line = BuildLine(lineLength); + private long _pos; + + private static byte[] BuildLine(int length) + { + var line = new byte[length]; + Array.Fill(line, (byte)'x'); + line[length - 1] = (byte)'\n'; + return line; + } + + public override int Read(byte[] buffer, int offset, int count) + { + for (var i = 0; i < count; i++) + buffer[offset + i] = _line[(_pos + i) % _line.Length]; + _pos += count; + return count; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => _pos; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + [Fact] public void Dearmor_Skips_Leading_Whitespace() { diff --git a/Age/AgeLimits.cs b/Age/AgeLimits.cs new file mode 100644 index 0000000..3fb34f2 --- /dev/null +++ b/Age/AgeLimits.cs @@ -0,0 +1,42 @@ +namespace Age; + +/// +/// Resource limits applied while reading an age file's header and ASCII armor, +/// before any bytes are authenticated. They exist only to stop a hostile or +/// malformed stream from exhausting memory: the header must be buffered whole to +/// verify its MAC, so without a ceiling an unterminated or endlessly repeated +/// line could be read until the process runs out of memory. +/// +/// +/// The age specification (C2SP age.md) defines no maximum header size, +/// stanza-argument length, or recipient count — a stanza argument is +/// 1*VCHAR with no upper bound — so these are AgeSharp's own defensive +/// limits, not a spec requirement. They are set far above any real age file: +/// the largest built-in stanza line (an ML-KEM-768 enc argument) is +/// ~1.5 KiB, and still permits well over a hundred +/// thousand recipients. If a legitimate file ever trips one of these, that is +/// the signal to make them configurable rather than to remove them. +/// +public static class AgeLimits +{ + /// + /// Maximum length, in bytes, of a single header line — the version line, a + /// stanza argument line, or a stanza body line. Default: 64 KiB. + /// + public const int MaxHeaderLineBytes = 64 * 1024; + + /// + /// Maximum total length, in bytes, of the header — every line up to and + /// including the --- <mac> line. Bounds a header padded out with + /// a huge number of small stanzas. Default: 16 MiB. + /// + public const int MaxHeaderBytes = 16 * 1024 * 1024; + + /// + /// Maximum length, in bytes, of a single ASCII-armor line. A spec-compliant + /// armor line is at most 64 characters; the ceiling is set high so it only + /// ever rejects a hostile unterminated line, never legitimate input. + /// Default: 64 KiB. + /// + public const int MaxArmorLineBytes = 64 * 1024; +} diff --git a/Age/Format/AsciiArmor.cs b/Age/Format/AsciiArmor.cs index 5df4b6c..7f928f8 100644 --- a/Age/Format/AsciiArmor.cs +++ b/Age/Format/AsciiArmor.cs @@ -43,7 +43,10 @@ private static void SkipLeadingWhitespace(Stream stream) public static Stream Dearmor(Stream input) { - var reader = new StreamReader(input, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, + // Bound the line length at the byte level so the reader below can keep + // using the fast ReadLine path without risking an unbounded allocation. + var bounded = new NewlineBoundedStream(input, AgeLimits.MaxArmorLineBytes); + var reader = new StreamReader(bounded, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 4096, leaveOpen: false); // Skip leading whitespace (allowed per spec). diff --git a/Age/Format/HeaderReader.cs b/Age/Format/HeaderReader.cs index 07034ae..848d547 100644 --- a/Age/Format/HeaderReader.cs +++ b/Age/Format/HeaderReader.cs @@ -58,6 +58,10 @@ public void PushBack(string line) break; ValidateByte(b); + + if (lineBytes.Count >= AgeLimits.MaxHeaderLineBytes) + throw new AgeHeaderException($"header line exceeds {AgeLimits.MaxHeaderLineBytes} bytes"); + lineBytes.Add((byte)b); } @@ -69,7 +73,12 @@ private int ReadAndTrackByte() var b = stream.ReadByte(); if (b >= 0) + { + if (_rawBytes.Length >= AgeLimits.MaxHeaderBytes) + throw new AgeHeaderException($"header exceeds {AgeLimits.MaxHeaderBytes} bytes"); + _rawBytes.WriteByte((byte)b); + } return b; } diff --git a/Age/Format/NewlineBoundedStream.cs b/Age/Format/NewlineBoundedStream.cs new file mode 100644 index 0000000..e20f3c0 --- /dev/null +++ b/Age/Format/NewlineBoundedStream.cs @@ -0,0 +1,84 @@ +namespace Age.Format; + +/// +/// A read-only pass-through stream that caps the number of bytes that may pass +/// without a line terminator (CR or LF). This lets the armor reader keep using +/// the fast path while still bounding memory: +/// a hostile stream with a multi-gigabyte line cannot be buffered, because the +/// limit trips during the underlying read instead. +/// +internal sealed class NewlineBoundedStream(Stream inner, int maxLineBytes) : Stream +{ + private int _run; // bytes seen since the last CR/LF + + public override int Read(byte[] buffer, int offset, int count) + { + var n = inner.Read(buffer, offset, count); + Scan(buffer.AsSpan(offset, n)); + return n; + } + + public override int Read(Span buffer) + { + var n = inner.Read(buffer); + Scan(buffer[..n]); + return n; + } + + private void Scan(ReadOnlySpan bytes) + { + // Fast path: when the carried run plus this whole chunk fits the limit, + // no line inside the chunk can violate it. One backward vectorized scan + // updates the carried run, so the bound costs ~one SIMD pass per read. + if (_run + bytes.Length <= maxLineBytes) + { + var lastNl = bytes.LastIndexOfAny((byte)'\n', (byte)'\r'); + _run = lastNl < 0 ? _run + bytes.Length : bytes.Length - 1 - lastNl; + return; + } + + // Slow path (a line has accumulated near the limit — hostile input): + // walk newline-to-newline to find where the run is broken or exceeded. + while (!bytes.IsEmpty) + { + var nl = bytes.IndexOfAny((byte)'\n', (byte)'\r'); + var lineLen = nl < 0 ? bytes.Length : nl; + + if (_run + lineLen > maxLineBytes) + throw new AgeArmorException($"armor line exceeds {maxLineBytes} bytes"); + + if (nl < 0) + { + _run += bytes.Length; + return; + } + + _run = 0; + bytes = bytes[(nl + 1)..]; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + inner.Dispose(); + + base.Dispose(disposing); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); +} diff --git a/README.md b/README.md index e1b6216..7a7c056 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,25 @@ public class MyIdentity : IIdentity } ``` +### Parsing limits + +An age header must be buffered in full before its MAC can be verified, so +AgeSharp caps how much it will read before authentication — otherwise a hostile +or truncated stream with an unterminated (or endlessly repeated) line could +exhaust memory. The limits are exposed as constants on `AgeLimits`: + +| Constant | Default | Bounds | +| --- | --- | --- | +| `AgeLimits.MaxHeaderLineBytes` | 64 KiB | A single header line | +| `AgeLimits.MaxHeaderBytes` | 16 MiB | The whole header (all stanzas) | +| `AgeLimits.MaxArmorLineBytes` | 64 KiB | A single ASCII-armor line | + +Exceeding a limit throws `AgeHeaderException` / `AgeArmorException`. The age +[specification](https://github.com/C2SP/C2SP/blob/main/age.md) sets no such +bounds, so these are AgeSharp's own defense; they sit far above any real file +(the largest built-in stanza line is ~1.5 KiB, and 16 MiB still allows well over +100,000 recipients), so legitimate input never trips them. + ## CLI `AgeSharp` ships a CLI compatible with the `age` command.