Skip to content
Open
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
154 changes: 117 additions & 37 deletions sdk/c/src/eipc_json.c
Original file line number Diff line number Diff line change
Expand Up @@ -15,60 +15,148 @@

/* ---------- JSON Parse Helpers ---------- */

static int json_find_string(const char *json, const char *key,
char *out, size_t out_size) {
#define JSON_BAD_HEX 0xFFFFFFFFu

/* The value of four hex digits at p, or JSON_BAD_HEX. */
static uint32_t json_hex4(const char *p) {
uint32_t v = 0;
int i;
for (i = 0; i < 4; i++) {
char c = p[i];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Truncated escapes read past input

When a JSON string ends with a truncated \u escape, json_hex4 reads four bytes without checking that they remain before the input terminator, causing an out-of-bounds read and undefined behavior.

Check that four hex digits remain before reading them, and handle truncated escapes without reading past the input.

Prompt for AI agents
In `sdk/c/src/eipc_json.c` at line 25:

**Truncated escapes read past input**

When a JSON string ends with a truncated `\u` escape, `json_hex4` reads four bytes without checking that they remain before the input terminator, causing an out-of-bounds read and undefined behavior.

Check that four hex digits remain before reading them, and handle truncated escapes without reading past the input.

v <<= 4;
if (c >= '0' && c <= '9')
v |= (uint32_t)(c - '0');
else if (c >= 'a' && c <= 'f')
v |= (uint32_t)(c - 'a' + 10);
else if (c >= 'A' && c <= 'F')
v |= (uint32_t)(c - 'A' + 10);
else
return JSON_BAD_HEX;
}
return v;
}

/* Write code point cp as UTF-8 into out (room for 4 bytes); return the length. */
static size_t json_utf8(uint32_t cp, char *out) {
if (cp < 0x80) {
out[0] = (char)cp;
return 1;
}
if (cp < 0x800) {
out[0] = (char)(0xC0 | (cp >> 6));
out[1] = (char)(0x80 | (cp & 0x3F));
return 2;
}
if (cp < 0x10000) {
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
}
out[0] = (char)(0xF0 | (cp >> 18));
out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}

/* The start of the value for key, or NULL. Only a "key" followed by a colon
* is a key: strstr() alone also matched the same text as a string value, so
* in {"status":"error","error":"disk full"} the lookup for "error" landed on
* the status value and returned nothing. */
static const char *json_find_value(const char *json, const char *key) {
char pattern[128];
const char *start, *end;
size_t len;
const char *p = json;
size_t plen;

snprintf(pattern, sizeof(pattern), "\"%s\"", key);
start = strstr(json, pattern);
if (!start) {
out[0] = '\0';
return -1;
plen = strlen(pattern);
while ((p = strstr(p, pattern)) != NULL) {
const char *v = p + plen;
while (*v == ' ' || *v == '\t' || *v == '\n' || *v == '\r')
v++;
if (*v == ':') {
Comment on lines +74 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Decoders return embedded field values

When an earlier string contains escaped field-like text, or a nested object contains the requested field before the root-level field, json_find_value scans raw text and accepts the first matching "key": without checking whether it is inside a string or at the requested object depth, so decoders return the embedded or nested value instead of the root-level field.

Parse the JSON while tracking strings, escapes, and object depth, and return only a matching root-level member.

Prompt for AI agents
In `sdk/c/src/eipc_json.c` at lines 74-78:

**Decoders return embedded field values**

When an earlier string contains escaped field-like text, or a nested object contains the requested field before the root-level field, `json_find_value` scans raw text and accepts the first matching `"key":` without checking whether it is inside a string or at the requested object depth, so decoders return the embedded or nested value instead of the root-level field.

Parse the JSON while tracking strings, escapes, and object depth, and return only a matching root-level member.

v++;
while (*v == ' ' || *v == '\t' || *v == '\n' || *v == '\r')
v++;
return v;
}
p += plen;
}
return NULL;
}

start += strlen(pattern);
while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n')
start++;
static int json_find_string(const char *json, const char *key,
char *out, size_t out_size) {
const char *start, *end;
size_t len;

if (*start != '"') {
start = json_find_value(json, key);
if (!start || *start != '"') {
out[0] = '\0';
return -1;
}
start++;

/* Decode the escapes as the value is copied: the encoders write \" \\
* \n \r \t (json_escape), and other JSON encoders also write \/ \b \f
* and \uXXXX (Go's encoding/json writes < > & as \u003c \u003e
* \u0026). A value longer than out_size - 1 bytes is truncated as
* before; a decoded \uXXXX is never cut in half. */
len = 0;
end = start;
while (*end && *end != '"') {
if (*end == '\\' && *(end + 1))
char enc[4];
size_t n = 1;

if (*end != '\\') {
enc[0] = *end++;
} else if (end[1] == 'u') {
uint32_t cp = json_hex4(end + 2);
end += (cp == JSON_BAD_HEX) ? 2 : 6;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed escapes alter decoded text

When input contains an unknown escape such as \q or an invalid \u sequence, json_find_string copies the escaped character and drops the backslash for unknown escapes, while invalid \u sequences can leave following characters to be copied; callers receive altered text instead of the documented U+FFFD replacement.

Replace each malformed escape with U+FFFD and consume the malformed escape so its remaining characters are not copied as ordinary text.

Also at sdk/c/src/eipc_json.c:136-137.

Prompt for AI agents
In `sdk/c/src/eipc_json.c` at line 116:

**Malformed escapes alter decoded text**

When input contains an unknown escape such as `\q` or an invalid `\u` sequence, `json_find_string` copies the escaped character and drops the backslash for unknown escapes, while invalid `\u` sequences can leave following characters to be copied; callers receive altered text instead of the documented U+FFFD replacement.

Replace each malformed escape with U+FFFD and consume the malformed escape so its remaining characters are not copied as ordinary text.

Also at `sdk/c/src/eipc_json.c:136-137`.

From SECURITY-STANDARDS.md L12-14:

Validate every input at the boundary it enters: type, range, length, format and
allowed values. Reject what does not match rather than coercing it. Validation
performed only in the client is not validation.

if (cp >= 0xD800 && cp <= 0xDBFF && end[0] == '\\' && end[1] == 'u') {
uint32_t lo = json_hex4(end + 2);
if (lo >= 0xDC00 && lo <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
end += 6;
}
}
/* A NUL would cut the C string short, so \u0000 becomes
* U+FFFD, like a malformed escape or an unpaired surrogate. */
if (cp == 0 || cp == JSON_BAD_HEX || (cp >= 0xD800 && cp <= 0xDFFF))
cp = 0xFFFD;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Malformed escape regressions go unnoticed

When an input contains malformed \u hex, an unpaired surrogate, or a trailing backslash, the U+FFFD handling and trailing-backslash branches have no regression assertions, so changes that break these decoder paths still pass the C tests and callers receive incorrectly decoded strings.

Add public-decoder regression tests asserting the specified output for malformed Unicode escapes, unpaired surrogates, and a trailing backslash.

Also at sdk/c/src/eipc_json.c:140.

Prompt for AI agents
In `sdk/c/src/eipc_json.c` at line 127:

**Malformed escape regressions go unnoticed**

When an input contains malformed `\u` hex, an unpaired surrogate, or a trailing backslash, the U+FFFD handling and trailing-backslash branches have no regression assertions, so changes that break these decoder paths still pass the C tests and callers receive incorrectly decoded strings.

Add public-decoder regression tests asserting the specified output for malformed Unicode escapes, unpaired surrogates, and a trailing backslash.

Also at `sdk/c/src/eipc_json.c:140`.

From TESTING.md L10:

- **Unit** — logic and its branches, including edge cases.

n = json_utf8(cp, enc);
} else if (end[1]) {
switch (end[1]) {
case 'b': enc[0] = '\b'; break;
case 'f': enc[0] = '\f'; break;
case 'n': enc[0] = '\n'; break;
case 'r': enc[0] = '\r'; break;
case 't': enc[0] = '\t'; break;
default: enc[0] = end[1]; break; /* \" \\ \/ */
}
end += 2;
else
end++;
} else {
break; /* a lone backslash at the end of the input */
}

if (len + n >= out_size)
break;
memcpy(out + len, enc, n);
len += n;
}

len = (size_t)(end - start);
if (len >= out_size)
len = out_size - 1;
memcpy(out, start, len);
out[len] = '\0';
return 0;
}

static int json_find_uint64(const char *json, const char *key, uint64_t *out) {
char pattern[128];
const char *start;

snprintf(pattern, sizeof(pattern), "\"%s\"", key);
start = strstr(json, pattern);
const char *start = json_find_value(json, key);
if (!start) {
*out = 0;
return -1;
}

start += strlen(pattern);
while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n')
start++;

if (*start == '"')
start++;
*out = (uint64_t)strtoull(start, NULL, 10);
Expand All @@ -86,20 +174,12 @@ static int json_find_int(const char *json, const char *key, int *out) {
}

static int json_find_float(const char *json, const char *key, float *out) {
char pattern[128];
const char *start;

snprintf(pattern, sizeof(pattern), "\"%s\"", key);
start = strstr(json, pattern);
const char *start = json_find_value(json, key);
if (!start) {
*out = 0.0f;
return -1;
}

start += strlen(pattern);
while (*start == ' ' || *start == ':' || *start == '\t' || *start == '\n')
start++;

if (*start == '"')
start++;
*out = (float)strtod(start, NULL);
Expand Down
76 changes: 76 additions & 0 deletions sdk/c/tests/test_chat_json.c
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,88 @@ static int test_chat_null_args(void) {
return 0;
}

/* The *_from_json decoders must undo JSON string escapes. They used to copy
* the bytes between the quotes verbatim, so a value written by
* eipc_ack_to_json() or eipc_intent_to_json() came back with its
* backslashes, and text from Go's encoding/json, which writes <, > and &
* as \u003c, \u003e and \u0026, came back as those six-character codes. */
static int test_json_string_escapes_decoded(void) {
eipc_ack_t ack, ack_parsed;
eipc_intent_t intent, intent_parsed;
eipc_chat_response_t resp;
char json[1024];
size_t written = 0;

memset(&ack, 0, sizeof(ack));
strncpy(ack.request_id, "req-1", sizeof(ack.request_id) - 1);
strncpy(ack.status, "failed", sizeof(ack.status) - 1);
strncpy(ack.error, "open \"C:\\tmp\"\nfailed\tagain", sizeof(ack.error) - 1);
ASSERT(eipc_ack_to_json(&ack, json, sizeof(json), &written) == EIPC_OK, "ack serialize");
ASSERT(eipc_ack_from_json(json, strlen(json), &ack_parsed) == EIPC_OK, "ack deserialize");
ASSERT(strcmp(ack_parsed.error, ack.error) == 0, "ack error round-trips");

memset(&intent, 0, sizeof(intent));
strncpy(intent.intent, "say \"hi\"", sizeof(intent.intent) - 1);
strncpy(intent.session_id, "sess-9", sizeof(intent.session_id) - 1);
intent.confidence = 0.5f;
ASSERT(eipc_intent_to_json(&intent, json, sizeof(json), &written) == EIPC_OK,
"intent serialize");
ASSERT(eipc_intent_from_json(json, strlen(json), &intent_parsed) == EIPC_OK,
"intent deserialize");
ASSERT(strcmp(intent_parsed.intent, "say \"hi\"") == 0, "intent round-trips");

/* Go's json.Marshal writes < and & as \u003c and \u0026; \/, a
* \u00e9 and a surrogate pair are valid JSON from any encoder. */
const char *from_go =
"{\"session_id\":\"s-1\","
"\"response\":\"a \\u003c b \\u0026 c \\u00e9 \\/ \\ud83d\\ude00\","
"\"model\":\"m\",\"tokens_used\":3}";
ASSERT(eipc_chat_response_from_json(from_go, strlen(from_go), &resp) == EIPC_OK,
"go response deserialize");
ASSERT(strcmp(resp.response, "a < b & c \xc3\xa9 / \xf0\x9f\x98\x80") == 0,
"\\u escapes decoded to UTF-8");
ASSERT(resp.tokens_used == 3, "tokens_used after escaped string");

/* A NUL would cut the C string short; \u0000 decodes to U+FFFD. */
const char *with_nul = "{\"intent\":\"a\\u0000b\",\"confidence\":1}";
ASSERT(eipc_intent_from_json(with_nul, strlen(with_nul), &intent_parsed) == EIPC_OK,
"intent with \\u0000 deserialize");
ASSERT(strcmp(intent_parsed.intent, "a\xEF\xBF\xBD" "b") == 0, "\\u0000 decoded to U+FFFD");
return 0;
}

/* A key is a quoted name followed by a colon. The lookup used strstr() on
* "error" alone, so it found the status value "error" first and returned
* an empty error message for every ack that reported a failure. */
static int test_json_key_not_matched_in_value(void) {
eipc_ack_t ack, parsed;
eipc_header_t hdr;
char json[512];
size_t written = 0;

memset(&ack, 0, sizeof(ack));
strncpy(ack.request_id, "req-2", sizeof(ack.request_id) - 1);
strncpy(ack.status, "error", sizeof(ack.status) - 1);
strncpy(ack.error, "disk full", sizeof(ack.error) - 1);
ASSERT(eipc_ack_to_json(&ack, json, sizeof(json), &written) == EIPC_OK, "ack serialize");
ASSERT(eipc_ack_from_json(json, strlen(json), &parsed) == EIPC_OK, "ack deserialize");
ASSERT(strcmp(parsed.status, "error") == 0, "status");
ASSERT(strcmp(parsed.error, "disk full") == 0, "error message after an \"error\" status");

const char *h = "{\"service_id\":\"priority\",\"priority\":2}";
ASSERT(eipc_header_from_json(h, strlen(h), &hdr) == EIPC_OK, "header deserialize");
ASSERT(hdr.priority == 2, "number after a string value equal to its key");
return 0;
}

int main(void) {
int p=0,f=0,t=0;
printf("=== EIPC Chat JSON Tests ===\n\n");
RUN(test_chat_request_json_roundtrip);
RUN(test_chat_response_json_roundtrip);
RUN(test_chat_null_args);
RUN(test_json_key_not_matched_in_value);
RUN(test_json_string_escapes_decoded);
printf("\n%d/%d passed, %d failed\n",p,t,f);
return f>0?1:0;
}
Loading