Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -461,15 +461,20 @@ private void writeRangeCheck(CppWriter w, RangeTrait range, String valueExpr, St

private void writePatternCheck(
CppWriter w, PatternTrait pattern, String valueExpr, String pathVar) {
rejectUnsupportedPattern(pattern);
String variable = "kPattern" + patternCounter++;
w.addInclude("<regex>");
// Raw string literal keeps the regex byte-exact; the failure message needs
// C++ escaping instead.
w.addInclude("\"smithy/core/regex.h\"");
// smithy::Regex is a linear-time engine, so no pattern/input combination
// can backtrack catastrophically (ReDoS). The raw string literal keeps
// the regex byte-exact; the failure message needs C++ escaping instead.
// Compile failure (impossible for generator-accepted patterns) fails
// closed: every value is rejected rather than skipping the check.
w.write(
"static const std::regex $L{R\"__smithy($L)__smithy\", std::regex::ECMAScript};",
"static const smithy::Outcome<smithy::Regex> $L ="
+ " smithy::Regex::Compile(R\"__smithy($L)__smithy\");",
variable,
pattern.getValue());
w.openBlock("if (!std::regex_search($L, $L)) {", valueExpr, variable);
w.openBlock("if (!$L.ok() || !$L->Search($L)) {", variable, variable, valueExpr);
w.write(
"AddValidationFailure(failures, $L, \"Value at '\" + $L + \"' failed to satisfy "
+ "constraint: Member must satisfy regular expression pattern: \" + "
Expand All @@ -480,6 +485,60 @@ private void writePatternCheck(
w.closeBlock("}");
}

/**
* Rejects @pattern regexes the linear-time runtime engine cannot support — backreferences and
* lookaround are inherently backtracking constructs. Everything else ECMA-262 offers (classes,
* anchors, quantifiers, groups, alternation) compiles to the NFA. Failing here keeps the contract
* visible at generation time instead of surfacing as an always-failing validator.
*/
private static void rejectUnsupportedPattern(PatternTrait pattern) {
String value = pattern.getValue();
boolean inClass = false;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == '\\') {
char next = i + 1 < value.length() ? value.charAt(i + 1) : '\0';
if (!inClass && next >= '1' && next <= '9') {
throw new software.amazon.smithy.codegen.core.CodegenException(
"@pattern "
+ value
+ " (at "
+ pattern.getSourceLocation()
+ ") uses a backreference (\\"
+ next
+ "), which the linear-time ReDoS-safe engine cannot support; rewrite the"
+ " pattern to repeat the group instead of referencing its capture");
}
i++; // Skip the escaped character.
continue;
}
if (inClass) {
inClass = c != ']';
continue;
}
if (c == '[') {
inClass = true;
continue;
}
if (c == '(' && i + 1 < value.length() && value.charAt(i + 1) == '?') {
char kind = i + 2 < value.length() ? value.charAt(i + 2) : '\0';
boolean lookbehind =
kind == '<'
&& i + 3 < value.length()
&& (value.charAt(i + 3) == '=' || value.charAt(i + 3) == '!');
if (kind == '=' || kind == '!' || lookbehind) {
throw new software.amazon.smithy.codegen.core.CodegenException(
"@pattern "
+ value
+ " (at "
+ pattern.getSourceLocation()
+ ") uses lookaround, which the linear-time ReDoS-safe engine cannot support;"
+ " rewrite the pattern to match the text directly");
}
}
}
}

private void writeUniqueItemsCheck(CppWriter w, String valueExpr, String pathVar) {
w.openBlock("{");
w.write("bool unique = true;");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ smithy.protocoltests.rpcv2Cbor#RpcV2Protocol server-request RpcV2CborSupportsNaN

# Absent query list params deserialize as unset members, not engaged empty lists.

# std::regex is a backtracking engine, so the deliberately catastrophic
# pattern ^([0-9]+)+$ hangs; ReDoS-safe @pattern matching needs a linear-time
# regex engine (tracked in PLAN).

# Nested @required absences (inside collections/structures) surface as strict
# deserialization errors without JSON-pointer paths; reporting them as fieldList
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,47 @@ void generatesRecursiveShapes() {
assertTrue(header.contains("struct TreeNode;"), header);
}

@Test
void rejectsBacktrackingOnlyPatterns() {
// Backreferences and lookaround need a backtracking engine; the
// linear-time ReDoS-safe matcher refuses them at generation time.
Model model =
Model.assembler()
.discoverModels(CppCodegenPluginTest.class.getClassLoader())
.addUnparsedModel(
"backref.smithy",
"""
$version: "2.0"
namespace test.redos
use smithy.cpp.protocols#jsonRpc2

@jsonRpc2
service Svc { version: "1", operations: [Op] }
operation Op {
input := {
@pattern("^(a+)\\\\1$")
doubled: String
}
}
""")
.assemble()
.unwrap();
PluginContext context =
PluginContext.builder()
.fileManifest(new MockManifest())
.model(model)
.settings(
Node.objectNodeBuilder()
.withMember("service", "test.redos#Svc")
.withMember("namespace", "test::redos")
.build())
.build();
CodegenException error =
assertThrows(CodegenException.class, () -> new CppCodegenPlugin().execute(context));
assertTrue(error.getMessage().contains("backreference"));
assertTrue(error.getMessage().contains("^(a+)\\1$"));
}

@Test
void rejectsRecursionThroughUnionMembers() {
Model model =
Expand Down
2 changes: 1 addition & 1 deletion docs/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ crates (PLAN §3.2a).

| Bazel target | Namespace | Contents |
|---|---|---|
| `//runtime:core` | `smithy` | `Outcome<T, E>` + `Error` (ADR-0003), `Blob`, `Timestamp` (epoch-seconds / RFC 3339 date-time / IMF-fixdate http-date), `Document` (dynamic value + serde pivot), base64 |
| `//runtime:core` | `smithy` | `Outcome<T, E>` + `Error` (ADR-0003), `Blob`, `Timestamp` (epoch-seconds / RFC 3339 date-time / IMF-fixdate http-date), `Document` (dynamic value + serde pivot), base64, `Regex` (linear-time NFA engine behind generated `@pattern` validation — ReDoS-safe) |
| `//runtime:json` | `smithy::json` | `Document` ⇄ JSON text via nlohmann (blobs as base64, timestamps per stored format) |
| `//runtime:cbor` | `smithy::cbor` | `Document` ⇄ deterministic CBOR (RFC 8949; tag-1 timestamps; tolerant decoder) — ADR-0005 |
| `//runtime:http` | `smithy::http` | `Headers` (case-insensitive), URI percent-encoding per the Smithy HTTP binding rules, `HttpRequest`/`HttpResponse`, `HttpClient`/`HttpServerTransport` interfaces, `Loopback` in-memory transport, built-in `SocketHttpClient`/`SocketHttpServer` (test/reference only — ADR-0006), W3C `TraceContext` parse/format/generate |
Expand Down
10 changes: 8 additions & 2 deletions docs/server-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ responds with the standard 400 `ValidationException` wire shape (`message` summa
the exact message formats the official validation conformance suite pins. `@internal` enum
members stay accepted on the wire but are omitted from the advertised value set.

`@pattern` evaluates on a linear-time NFA engine (`smithy/core/regex.h`), so no pattern/input
combination can backtrack catastrophically — the classic ReDoS pattern `^([0-9]+)+$` validates
request-sized inputs in microseconds instead of hanging the dispatch thread. The engine covers
the ECMA-262 subset Smithy patterns use; backreferences and lookaround (inherently
backtracking constructs) are rejected at generation time with an error naming the shape and
the fix.

## Conformance

Generated servers pass the official server-mode `httpRequestTests`/`httpResponseTests` suites
Expand All @@ -101,5 +108,4 @@ serialization error) — see [production-guide.md](production-guide.md).

Nested `@required` absences as `fieldList` entries and a server-strict serde variant (clients
must skip null dense-map values and accept UTC-offset timestamps in responses; servers share
that serde today), ReDoS-safe `@pattern` matching (`std::regex` backtracks, so the suite's
deliberately catastrophic pattern is excluded), and `@streaming` payloads (Phase 8).
that serde today), and `@streaming` payloads (Phase 8).
14 changes: 7 additions & 7 deletions examples/roundtrip/rest/generated/src/server.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
#include <cstdlib>
#include <limits>
#include <memory>
#include <regex>
#include <string>
#include <string_view>
#include <utility>
Expand All @@ -20,6 +19,7 @@
#include "smithy/core/blob.h"
#include "smithy/core/document.h"
#include "smithy/core/document_serde.h"
#include "smithy/core/regex.h"
#include "smithy/core/text.h"
#include "smithy/http/headers.h"
#include "smithy/json/json.h"
Expand Down Expand Up @@ -136,8 +136,8 @@ void ValidateDescribeSinkInput(const DescribeSinkInput& value, const std::string
AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive");
}
}
static const std::regex kPattern0{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.sinkId, kPattern0)) {
static const smithy::Outcome<smithy::Regex> kPattern0 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9]+$)__smithy");
if (!kPattern0.ok() || !kPattern0->Search(value.sinkId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$"));
}
}
Expand Down Expand Up @@ -175,8 +175,8 @@ void ValidatePutSinkInput(const PutSinkInput& value, const std::string& path, st
AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive");
}
}
static const std::regex kPattern1{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.sinkId, kPattern1)) {
static const smithy::Outcome<smithy::Regex> kPattern1 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9]+$)__smithy");
if (!kPattern1.ok() || !kPattern1->Search(value.sinkId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$"));
}
}
Expand Down Expand Up @@ -207,8 +207,8 @@ void ValidateUploadAttachmentInput(const UploadAttachmentInput& value, const std
AddValidationFailure(failures, member_path, "Value with length " + std::to_string(member_length) + " at '" + member_path + "' failed to satisfy constraint: Member must have length between 1 and 32, inclusive");
}
}
static const std::regex kPattern2{R"__smithy(^[A-Za-z0-9]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.sinkId, kPattern2)) {
static const smithy::Outcome<smithy::Regex> kPattern2 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9]+$)__smithy");
if (!kPattern2.ok() || !kPattern2->Search(value.sinkId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9]+$"));
}
}
Expand Down
14 changes: 7 additions & 7 deletions examples/weather/generated/src/server.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
#include <cstdlib>
#include <limits>
#include <memory>
#include <regex>
#include <string>
#include <string_view>
#include <utility>
Expand All @@ -20,6 +19,7 @@
#include "smithy/core/blob.h"
#include "smithy/core/document.h"
#include "smithy/core/document_serde.h"
#include "smithy/core/regex.h"
#include "smithy/http/headers.h"
#include "smithy/json/json.h"
#include "smithy/server/router.h"
Expand Down Expand Up @@ -110,8 +110,8 @@ void AddValidationFailure(std::vector<smithy::server::ValidationFailure>* failur
void ValidateDeleteCityInput(const DeleteCityInput& value, const std::string& path, std::vector<smithy::server::ValidationFailure>* failures) {
{
const std::string member_path = path + "/cityId";
static const std::regex kPattern0{R"__smithy(^[A-Za-z0-9 ]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.cityId, kPattern0)) {
static const smithy::Outcome<smithy::Regex> kPattern0 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9 ]+$)__smithy");
if (!kPattern0.ok() || !kPattern0->Search(value.cityId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9 ]+$"));
}
}
Expand All @@ -120,8 +120,8 @@ void ValidateDeleteCityInput(const DeleteCityInput& value, const std::string& pa
void ValidateGetForecastInput(const GetForecastInput& value, const std::string& path, std::vector<smithy::server::ValidationFailure>* failures) {
{
const std::string member_path = path + "/cityId";
static const std::regex kPattern1{R"__smithy(^[A-Za-z0-9 ]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.cityId, kPattern1)) {
static const smithy::Outcome<smithy::Regex> kPattern1 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9 ]+$)__smithy");
if (!kPattern1.ok() || !kPattern1->Search(value.cityId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9 ]+$"));
}
}
Expand All @@ -130,8 +130,8 @@ void ValidateGetForecastInput(const GetForecastInput& value, const std::string&
void ValidateGetCityInput(const GetCityInput& value, const std::string& path, std::vector<smithy::server::ValidationFailure>* failures) {
{
const std::string member_path = path + "/cityId";
static const std::regex kPattern2{R"__smithy(^[A-Za-z0-9 ]+$)__smithy", std::regex::ECMAScript};
if (!std::regex_search(value.cityId, kPattern2)) {
static const smithy::Outcome<smithy::Regex> kPattern2 = smithy::Regex::Compile(R"__smithy(^[A-Za-z0-9 ]+$)__smithy");
if (!kPattern2.ok() || !kPattern2->Search(value.cityId)) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[A-Za-z0-9 ]+$"));
}
}
Expand Down
1 change: 1 addition & 0 deletions fuzz/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ FUZZ_TARGETS = {
"//runtime:cbor",
"//runtime:core",
],
"regex": ["//runtime:core"],
"uri": ["//runtime:http"],
"server_dispatch": [
"//examples/weather/generated:server",
Expand Down
24 changes: 24 additions & 0 deletions fuzz/regex_fuzz.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Fuzz target: the linear-time @pattern engine. Compile must never crash on
// arbitrary pattern bytes, and anything it accepts must Search arbitrary
// input in bounded time (the harness splits the record into pattern and
// text at the first NUL). ReDoS-resistance is the engine's contract, so a
// hang here is a finding, not flakiness.
#include <cstddef>
#include <cstdint>
#include <string_view>

#include "smithy/core/regex.h"

extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) {
const std::string_view record(reinterpret_cast<const char*>(data), size);
const std::size_t split = record.find('\0');
const std::string_view pattern =
split == std::string_view::npos ? record : record.substr(0, split);
const std::string_view text = split == std::string_view::npos ? "" : record.substr(split + 1);
auto re = smithy::Regex::Compile(pattern);
if (re.ok()) {
(void)re->Search(text);
(void)re->Search(pattern);
}
return 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ struct NoArgsOutput {
struct PutConstrainedInput {
std::string name{};
std::optional<std::int32_t> limit{};
std::optional<std::string> slug{};
std::optional<std::string> evilDigits{};

friend bool operator==(const PutConstrainedInput&, const PutConstrainedInput&) = default;
};
Expand Down
24 changes: 24 additions & 0 deletions protocol-tests/jsonrpc2/generated/src/serde.cc
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,12 @@ smithy::Document SerializePutConstrainedInput(const PutConstrainedInput& value)
if (value.limit.has_value()) {
map.emplace("limit", smithy::Document(static_cast<std::int64_t>((*value.limit))));
}
if (value.slug.has_value()) {
map.emplace("slug", smithy::Document((*value.slug)));
}
if (value.evilDigits.has_value()) {
map.emplace("evilDigits", smithy::Document((*value.evilDigits)));
}
return smithy::Document(std::move(map));
}

Expand All @@ -396,6 +402,24 @@ smithy::Outcome<PutConstrainedInput> DeserializePutConstrainedInput(const smithy
out.limit = std::move(parsed_member);
}
}
{
const smithy::Document* member = doc.Find("slug");
if (member != nullptr && !member->is_null()) {
std::string parsed_member{};
if (!member->is_string()) return smithy::Error::Serialization("PutConstrainedInput.slug: unexpected type on the wire");
parsed_member = member->as_string();
out.slug = std::move(parsed_member);
}
}
{
const smithy::Document* member = doc.Find("evilDigits");
if (member != nullptr && !member->is_null()) {
std::string parsed_member{};
if (!member->is_string()) return smithy::Error::Serialization("PutConstrainedInput.evilDigits: unexpected type on the wire");
parsed_member = member->as_string();
out.evilDigits = std::move(parsed_member);
}
}
return out;
}

Expand Down
15 changes: 15 additions & 0 deletions protocol-tests/jsonrpc2/generated/src/server.cc
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <vector>

#include "smithy/core/document.h"
#include "smithy/core/regex.h"
#include "smithy/core/text.h"
#include "smithy/http/headers.h"
#include "smithy/json/json.h"
Expand Down Expand Up @@ -96,6 +97,20 @@ void ValidatePutConstrainedInput(const PutConstrainedInput& value, const std::st
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must be between 1 and 100, inclusive");
}
}
if (value.slug.has_value()) {
const std::string member_path = path + "/slug";
static const smithy::Outcome<smithy::Regex> kPattern0 = smithy::Regex::Compile(R"__smithy(^[a-z0-9-]+$)__smithy");
if (!kPattern0.ok() || !kPattern0->Search((*value.slug))) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^[a-z0-9-]+$"));
}
}
if (value.evilDigits.has_value()) {
const std::string member_path = path + "/evilDigits";
static const smithy::Outcome<smithy::Regex> kPattern1 = smithy::Regex::Compile(R"__smithy(^([0-9]+)+$)__smithy");
if (!kPattern1.ok() || !kPattern1->Search((*value.evilDigits))) {
AddValidationFailure(failures, member_path, "Value at '" + member_path + "' failed to satisfy constraint: Member must satisfy regular expression pattern: " + std::string("^([0-9]+)+$"));
}
}
}

smithy::http::HttpResponse ValidationErrorResponse(const std::vector<smithy::server::ValidationFailure>& failures, const smithy::Document& id) {
Expand Down
Loading
Loading