@nextrush/stream 1.0.0-beta.0 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextrush/stream",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
4
|
"description": "Runtime-agnostic response streaming (text/SSE/NDJSON) for NextRush — built for AI/agentic apps",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@nextrush/types": "4.0.0-beta.
|
|
21
|
+
"@nextrush/types": "4.0.0-beta.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"tsup": "^8.5.1",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/stream - Security: SSE/NDJSON framing injection from attacker-controlled data
|
|
3
|
+
*
|
|
4
|
+
* Part of the `audit-unreviewed-security-surface` investigation (task 6.1/6.2).
|
|
5
|
+
* `sse-format.ts`'s `formatSSE()` already strips CR/LF from `event`/`id`
|
|
6
|
+
* (see its own `strips CR/LF from event and id to prevent field injection`
|
|
7
|
+
* test in `stream.test.ts`) — this file adds the audit's own evidence pass,
|
|
8
|
+
* confirming that protection holds specifically for attacker-shaped payloads
|
|
9
|
+
* (a value containing a fake `event:`/`id:`/blank-line sequence, not just a
|
|
10
|
+
* bare `\n`), and that NDJSON's `JSON.stringify`-based framing cannot be
|
|
11
|
+
* broken by embedded newlines in string values.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { runNDJSONStream, runSSEStream } from '../run';
|
|
16
|
+
import { formatSSE } from '../sse-format';
|
|
17
|
+
|
|
18
|
+
function createMockCtx() {
|
|
19
|
+
const ac = new AbortController();
|
|
20
|
+
const headers: Record<string, string> = {};
|
|
21
|
+
let collectedPromise: Promise<Uint8Array[]> = Promise.resolve([]);
|
|
22
|
+
return {
|
|
23
|
+
signal: ac.signal,
|
|
24
|
+
headers,
|
|
25
|
+
set(field: string, value: string | number | string[]) {
|
|
26
|
+
headers[field] = String(value);
|
|
27
|
+
},
|
|
28
|
+
sendStream(rs: ReadableStream<Uint8Array>) {
|
|
29
|
+
collectedPromise = (async () => {
|
|
30
|
+
const reader = rs.getReader();
|
|
31
|
+
const chunks: Uint8Array[] = [];
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done) break;
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
return chunks;
|
|
38
|
+
})();
|
|
39
|
+
return collectedPromise.then(() => undefined);
|
|
40
|
+
},
|
|
41
|
+
async collected() {
|
|
42
|
+
const chunks = await collectedPromise;
|
|
43
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('SSE/NDJSON framing — no attacker-controlled event injection (audit-unreviewed-security-surface, 6.1/6.2)', () => {
|
|
49
|
+
it('an attacker-shaped id containing a forged "event:"/blank-line sequence cannot inject a second, fake event', () => {
|
|
50
|
+
// If the CR/LF strip were absent, this payload would render as two
|
|
51
|
+
// distinct SSE events: the real one, plus a forged "malicious-event"
|
|
52
|
+
// carrying attacker-controlled data — because a bare \n inside `id:`
|
|
53
|
+
// would terminate the field and start a new one.
|
|
54
|
+
const attackerId = 'real-id\n\nevent: malicious-event\ndata: forged-payload';
|
|
55
|
+
const out = formatSSE({ data: 'legit', id: attackerId });
|
|
56
|
+
|
|
57
|
+
// The entire attacker string collapses onto a single id: line with no \n —
|
|
58
|
+
// the literal text survives (': ', 'data:'), only the \n bytes that would
|
|
59
|
+
// have started a new SSE field/event are stripped.
|
|
60
|
+
expect(out).toBe('id: real-idevent: malicious-eventdata: forged-payload\ndata: legit\n\n');
|
|
61
|
+
// Critically: only one blank-line-terminated event exists in the output.
|
|
62
|
+
expect(out.split('\n\n').filter(Boolean)).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('an attacker-shaped event field containing CRLF (not just LF) is also fully stripped', () => {
|
|
66
|
+
const out = formatSSE({ data: 'x', event: 'ok\r\ndata: injected\r\n\r\nevent: fake' });
|
|
67
|
+
expect(out).not.toContain('\r');
|
|
68
|
+
expect(out).not.toContain('\n\n\n'); // no extra event boundary introduced
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('ctx.sse() end-to-end: an attacker-controlled id/event value passed to write() cannot forge an extra event in the wire output', async () => {
|
|
72
|
+
const ctx = createMockCtx();
|
|
73
|
+
const attackerControlled = 'token\nevent: fake-admin-message\ndata: you are now admin';
|
|
74
|
+
|
|
75
|
+
await runSSEStream(ctx, async (w) => {
|
|
76
|
+
await w.write({ data: 'real-payload', id: attackerControlled, event: 'legit-event' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const wire = await ctx.collected();
|
|
80
|
+
// Exactly one event (one blank-line terminator), regardless of what the
|
|
81
|
+
// attacker put in id/event.
|
|
82
|
+
expect(wire.split('\n\n').filter(Boolean)).toHaveLength(1);
|
|
83
|
+
expect(wire).toContain('data: real-payload');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('ctx.ndjson() end-to-end: a string value containing a raw newline cannot forge an extra NDJSON line, because JSON.stringify escapes it', async () => {
|
|
87
|
+
const ctx = createMockCtx();
|
|
88
|
+
const attackerControlled = 'line1\nFAKE_JSON_LINE\nline2';
|
|
89
|
+
|
|
90
|
+
await runNDJSONStream(ctx, async (w) => {
|
|
91
|
+
await w.write({ message: attackerControlled });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const wire = await ctx.collected();
|
|
95
|
+
// Exactly one NDJSON record (one trailing \n from the writer, none
|
|
96
|
+
// embedded inside the JSON value itself — JSON.stringify renders the
|
|
97
|
+
// value's \n as the escape sequence \\n, not a literal byte).
|
|
98
|
+
const lines = wire.split('\n').filter(Boolean);
|
|
99
|
+
expect(lines).toHaveLength(1);
|
|
100
|
+
expect(JSON.parse(lines[0]!)).toEqual({ message: attackerControlled });
|
|
101
|
+
});
|
|
102
|
+
});
|