@specific.dev/spectest 0.115.0 → 0.116.0
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/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +5 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +4 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/activity.test.ts +20 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Match one request dimension. A bare string is an exact match; the
|
|
2
|
+
* object forms cover prefix and regex matching. */
|
|
3
|
+
export type StringMatch = string | {
|
|
4
|
+
exact: string;
|
|
5
|
+
} | {
|
|
6
|
+
startsWith: string;
|
|
7
|
+
} | {
|
|
8
|
+
regex: string;
|
|
9
|
+
};
|
|
10
|
+
/** Request matchers for a credential-injection rule. A rule applies only
|
|
11
|
+
* when EVERY specified dimension matches; omit `match` to always apply. */
|
|
12
|
+
export interface InjectMatch {
|
|
13
|
+
/** Match the request path (no query). */
|
|
14
|
+
path?: StringMatch;
|
|
15
|
+
/** HTTP method(s) — any one matching satisfies it. */
|
|
16
|
+
method?: string | string[];
|
|
17
|
+
/** Query-param matchers (ANDed); each key's value(s) must match. */
|
|
18
|
+
query?: Record<string, StringMatch>;
|
|
19
|
+
/** Header matchers (ANDed; header names case-insensitive). */
|
|
20
|
+
headers?: Record<string, StringMatch>;
|
|
21
|
+
}
|
|
22
|
+
/** One credential-brokering rule. When it matches a request, these headers
|
|
23
|
+
* are SET on the egress forward — overwriting whatever the app sent (so app
|
|
24
|
+
* code can't smuggle a different value past the broker). Header VALUES may
|
|
25
|
+
* embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
|
|
26
|
+
* project's Secrets store and supplied for the environment lifetime. Values
|
|
27
|
+
* never enter project files, the warm-cache hash, or a cassette (redaction
|
|
28
|
+
* fails closed). */
|
|
29
|
+
export interface InjectRule {
|
|
30
|
+
match?: InjectMatch;
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
}
|
|
33
|
+
/** SigV4 request-signing broker. Unlike `inject` (which SETs a static header
|
|
34
|
+
* value), this RE-SIGNS the outbound request: the app signs with throwaway
|
|
35
|
+
* dummy credentials, we strip that signature and re-sign the exact outbound
|
|
36
|
+
* request with the real credentials — the only way to broker AWS auth, whose
|
|
37
|
+
* signature is a keyed HMAC over the whole request, not a static token.
|
|
38
|
+
*
|
|
39
|
+
* The three fields are **secret refs, resolved directly** from the project's
|
|
40
|
+
* Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
|
|
41
|
+
* fail-closed redaction as `inject`. Region + service are inferred from the
|
|
42
|
+
* incoming request (its credential scope, else the host); the app never
|
|
43
|
+
* configures them. */
|
|
44
|
+
export interface AwsSigV4Sign {
|
|
45
|
+
type: "awsSigv4";
|
|
46
|
+
/** Secret ref for the AWS access key id. */
|
|
47
|
+
accessKeyId: string;
|
|
48
|
+
/** Secret ref for the AWS secret access key. */
|
|
49
|
+
secretAccessKey: string;
|
|
50
|
+
/** Optional secret ref for a session token (temporary credentials). */
|
|
51
|
+
sessionToken?: string;
|
|
52
|
+
}
|
|
53
|
+
export type SignConfig = AwsSigV4Sign;
|
|
54
|
+
/** One resolved secret value and the ref it came from (for the redaction
|
|
55
|
+
* placeholder). */
|
|
56
|
+
export interface ResolvedSecret {
|
|
57
|
+
ref: string;
|
|
58
|
+
value: string;
|
|
59
|
+
}
|
|
60
|
+
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
61
|
+
* (we decompress on the way through, so the encoding/length no longer
|
|
62
|
+
* describe the bytes the app receives). */
|
|
63
|
+
export declare const STRIP_FORWARD_HEADERS: Set<string>;
|
|
64
|
+
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
65
|
+
* in the query for presigned URLs — they change every call and would poison
|
|
66
|
+
* query matching, so they're dropped from both the match key and the forward
|
|
67
|
+
* when signing is active. */
|
|
68
|
+
export declare function isAmzParam(key: string): boolean;
|
|
69
|
+
/** Every secret ref referenced by any inject rule's header templates plus the
|
|
70
|
+
* SigV4 signing credentials (direct refs, not templates). */
|
|
71
|
+
export declare function collectSecretRefs(rules: InjectRule[], sign: SignConfig | undefined): string[];
|
|
72
|
+
interface IncomingRequest {
|
|
73
|
+
method: string;
|
|
74
|
+
path: string;
|
|
75
|
+
query: URLSearchParams;
|
|
76
|
+
headers: Headers;
|
|
77
|
+
}
|
|
78
|
+
/** True if every dimension the matcher specifies is satisfied (AND across
|
|
79
|
+
* dimensions). No `match` → always true. */
|
|
80
|
+
export declare function ruleMatches(match: InjectMatch | undefined, req: IncomingRequest): boolean;
|
|
81
|
+
interface BrokeredHeaders {
|
|
82
|
+
/** Lowercased header → resolved value, to SET on the forward. */
|
|
83
|
+
headers: Record<string, string>;
|
|
84
|
+
/** Resolved secrets (for redaction). */
|
|
85
|
+
secrets: ResolvedSecret[];
|
|
86
|
+
/** Refs referenced but not supplied by the control plane. */
|
|
87
|
+
missing: string[];
|
|
88
|
+
}
|
|
89
|
+
/** Resolve a rule's header templates, substituting every `{{secret:REF}}`
|
|
90
|
+
* with the environment credential. Collects resolved secrets (for redaction)
|
|
91
|
+
* and any refs the control plane didn't supply (fail loud). */
|
|
92
|
+
export declare function brokerHeaders(rule: InjectRule): BrokeredHeaders;
|
|
93
|
+
/** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
|
|
94
|
+
* in-VM resolver (which would answer with the daemon's own gateway for a
|
|
95
|
+
* faked host). Mirrors spectest-resolver's own upstream-forward transport
|
|
96
|
+
* (dns-packet over UDP) so it works identically in-VM. */
|
|
97
|
+
export declare function resolveExternalA(host: string): Promise<string[]>;
|
|
98
|
+
interface UpstreamResponse {
|
|
99
|
+
status: number;
|
|
100
|
+
headers: Record<string, string>;
|
|
101
|
+
/** Keep cookie boundaries when returning an unrecorded response. */
|
|
102
|
+
cookies?: string[];
|
|
103
|
+
/** Decompressed body bytes. */
|
|
104
|
+
bytes: Uint8Array;
|
|
105
|
+
}
|
|
106
|
+
/** Forward without capturing. Callers may separately record the result. */
|
|
107
|
+
export declare function forwardHttp(req: Request, host: string, opts: {
|
|
108
|
+
inject?: InjectRule[];
|
|
109
|
+
sign?: SignConfig;
|
|
110
|
+
}, bodyBytes: Uint8Array, label: string): Promise<Response | {
|
|
111
|
+
upstream: UpstreamResponse;
|
|
112
|
+
secrets: ResolvedSecret[];
|
|
113
|
+
}>;
|
|
114
|
+
export {};
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Shared upstream transport and credential brokering. No cassette state lives here.
|
|
2
|
+
import { createSocket } from "node:dgram";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
5
|
+
import * as dnsPacket from "dns-packet";
|
|
6
|
+
import { getRecordSecret } from "./record-secrets.js";
|
|
7
|
+
import { inferSigV4Scope, signAwsV4Request } from "./aws-sigv4.js";
|
|
8
|
+
/** Hop-by-hop + body-framing headers never forwarded / never replayed
|
|
9
|
+
* (we decompress on the way through, so the encoding/length no longer
|
|
10
|
+
* describe the bytes the app receives). */
|
|
11
|
+
export const STRIP_FORWARD_HEADERS = new Set([
|
|
12
|
+
"host",
|
|
13
|
+
"connection",
|
|
14
|
+
"keep-alive",
|
|
15
|
+
"proxy-authenticate",
|
|
16
|
+
"proxy-authorization",
|
|
17
|
+
"te",
|
|
18
|
+
"trailers",
|
|
19
|
+
"transfer-encoding",
|
|
20
|
+
"upgrade",
|
|
21
|
+
"content-length",
|
|
22
|
+
"content-encoding",
|
|
23
|
+
// We force `accept-encoding: identity` on the forward and decompress any
|
|
24
|
+
// residual encoding, so the app never sees a stale accept-encoding.
|
|
25
|
+
"accept-encoding",
|
|
26
|
+
]);
|
|
27
|
+
/** External resolver used to find the REAL upstream IP, bypassing the in-VM
|
|
28
|
+
* resolver (which points the faked host back at the daemon). */
|
|
29
|
+
const EXTERNAL_DNS = process.env.SPECTEST_UPSTREAM_DNS ?? "1.1.1.1";
|
|
30
|
+
const EXTERNAL_DNS_PORT = Number(process.env.SPECTEST_UPSTREAM_PORT ?? "53");
|
|
31
|
+
/** SigV4 puts per-request signing params (`X-Amz-Signature`, `X-Amz-Date`, …)
|
|
32
|
+
* in the query for presigned URLs — they change every call and would poison
|
|
33
|
+
* query matching, so they're dropped from both the match key and the forward
|
|
34
|
+
* when signing is active. */
|
|
35
|
+
export function isAmzParam(key) {
|
|
36
|
+
return key.toLowerCase().startsWith("x-amz-");
|
|
37
|
+
}
|
|
38
|
+
function stripAmzParams(search) {
|
|
39
|
+
const out = new URLSearchParams();
|
|
40
|
+
for (const [k, v] of search)
|
|
41
|
+
if (!isAmzParam(k))
|
|
42
|
+
out.append(k, v);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/** `{{secret:REF}}` template token embedded in an injected header value. */
|
|
46
|
+
const SECRET_TOKEN = /\{\{secret:([A-Za-z0-9_]+)\}\}/g;
|
|
47
|
+
/** Every secret ref referenced by any inject rule's header templates plus the
|
|
48
|
+
* SigV4 signing credentials (direct refs, not templates). */
|
|
49
|
+
export function collectSecretRefs(rules, sign) {
|
|
50
|
+
const refs = new Set();
|
|
51
|
+
for (const rule of rules) {
|
|
52
|
+
for (const value of Object.values(rule.headers)) {
|
|
53
|
+
for (const m of value.matchAll(SECRET_TOKEN))
|
|
54
|
+
refs.add(m[1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (sign?.type === "awsSigv4") {
|
|
58
|
+
refs.add(sign.accessKeyId);
|
|
59
|
+
refs.add(sign.secretAccessKey);
|
|
60
|
+
if (sign.sessionToken)
|
|
61
|
+
refs.add(sign.sessionToken);
|
|
62
|
+
}
|
|
63
|
+
return [...refs];
|
|
64
|
+
}
|
|
65
|
+
function matchString(m, value) {
|
|
66
|
+
if (typeof m === "string")
|
|
67
|
+
return value === m;
|
|
68
|
+
if ("exact" in m)
|
|
69
|
+
return value === m.exact;
|
|
70
|
+
if ("startsWith" in m)
|
|
71
|
+
return value.startsWith(m.startsWith);
|
|
72
|
+
if ("regex" in m)
|
|
73
|
+
return new RegExp(m.regex).test(value);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
/** True if every dimension the matcher specifies is satisfied (AND across
|
|
77
|
+
* dimensions). No `match` → always true. */
|
|
78
|
+
export function ruleMatches(match, req) {
|
|
79
|
+
if (!match)
|
|
80
|
+
return true;
|
|
81
|
+
if (match.path !== undefined && !matchString(match.path, req.path))
|
|
82
|
+
return false;
|
|
83
|
+
if (match.method !== undefined) {
|
|
84
|
+
const methods = (Array.isArray(match.method) ? match.method : [match.method]).map((s) => s.toUpperCase());
|
|
85
|
+
if (!methods.includes(req.method.toUpperCase()))
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (match.query) {
|
|
89
|
+
for (const [k, m] of Object.entries(match.query)) {
|
|
90
|
+
if (!req.query.getAll(k).some((v) => matchString(m, v)))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (match.headers) {
|
|
95
|
+
for (const [k, m] of Object.entries(match.headers)) {
|
|
96
|
+
const v = req.headers.get(k.toLowerCase());
|
|
97
|
+
if (v === null || !matchString(m, v))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Resolve a rule's header templates, substituting every `{{secret:REF}}`
|
|
104
|
+
* with the environment credential. Collects resolved secrets (for redaction)
|
|
105
|
+
* and any refs the control plane didn't supply (fail loud). */
|
|
106
|
+
export function brokerHeaders(rule) {
|
|
107
|
+
const headers = {};
|
|
108
|
+
const secrets = [];
|
|
109
|
+
const missing = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const [name, template] of Object.entries(rule.headers)) {
|
|
112
|
+
headers[name.toLowerCase()] = template.replace(SECRET_TOKEN, (_full, ref) => {
|
|
113
|
+
const value = getRecordSecret(ref);
|
|
114
|
+
if (value === undefined) {
|
|
115
|
+
missing.push(ref);
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
if (!seen.has(ref)) {
|
|
119
|
+
seen.add(ref);
|
|
120
|
+
secrets.push({ ref, value });
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { headers, secrets, missing };
|
|
126
|
+
}
|
|
127
|
+
/** Resolve a host's A records against an EXTERNAL DNS server, bypassing the
|
|
128
|
+
* in-VM resolver (which would answer with the daemon's own gateway for a
|
|
129
|
+
* faked host). Mirrors spectest-resolver's own upstream-forward transport
|
|
130
|
+
* (dns-packet over UDP) so it works identically in-VM. */
|
|
131
|
+
export function resolveExternalA(host) {
|
|
132
|
+
const query = dnsPacket.encode({
|
|
133
|
+
type: "query",
|
|
134
|
+
id: 1,
|
|
135
|
+
flags: dnsPacket.RECURSION_DESIRED,
|
|
136
|
+
questions: [{ type: "A", name: host }],
|
|
137
|
+
});
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const sock = createSocket("udp4");
|
|
140
|
+
let done = false;
|
|
141
|
+
const finish = (ips) => {
|
|
142
|
+
if (done)
|
|
143
|
+
return;
|
|
144
|
+
done = true;
|
|
145
|
+
try {
|
|
146
|
+
sock.close();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// ignore
|
|
150
|
+
}
|
|
151
|
+
resolve(ips);
|
|
152
|
+
};
|
|
153
|
+
sock.on("message", (msg) => {
|
|
154
|
+
try {
|
|
155
|
+
const res = dnsPacket.decode(msg);
|
|
156
|
+
const ips = (res.answers ?? []).flatMap((a) => a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : []);
|
|
157
|
+
finish(ips);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
finish([]);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
sock.on("error", () => finish([]));
|
|
164
|
+
sock.send(query, EXTERNAL_DNS_PORT, EXTERNAL_DNS, (err) => {
|
|
165
|
+
if (err)
|
|
166
|
+
finish([]);
|
|
167
|
+
});
|
|
168
|
+
setTimeout(() => finish([]), 3_000);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Open a real HTTPS connection to `ip` but with SNI + `Host` = `host`, so
|
|
172
|
+
* the public certificate validates against the real hostname (the in-VM CA
|
|
173
|
+
* is not involved on this leg). Decompresses the response so the stored and
|
|
174
|
+
* returned bytes are the plain payload. */
|
|
175
|
+
function forwardToRealUpstream(args) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const req = httpsRequest({
|
|
178
|
+
host: args.ip,
|
|
179
|
+
port: 443,
|
|
180
|
+
method: args.method,
|
|
181
|
+
signal: args.signal,
|
|
182
|
+
path: args.pathWithQuery,
|
|
183
|
+
servername: args.host,
|
|
184
|
+
headers: { ...args.headers, host: args.host, "accept-encoding": "identity" },
|
|
185
|
+
}, (res) => {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
res.on("data", (c) => chunks.push(c));
|
|
188
|
+
res.on("end", () => {
|
|
189
|
+
let bytes = Buffer.concat(chunks);
|
|
190
|
+
const enc = String(res.headers["content-encoding"] ?? "").toLowerCase();
|
|
191
|
+
try {
|
|
192
|
+
if (enc.includes("br"))
|
|
193
|
+
bytes = brotliDecompressSync(bytes);
|
|
194
|
+
else if (enc.includes("gzip"))
|
|
195
|
+
bytes = gunzipSync(bytes);
|
|
196
|
+
else if (enc.includes("deflate"))
|
|
197
|
+
bytes = inflateSync(bytes);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Leave the bytes as-is if decompression fails.
|
|
201
|
+
}
|
|
202
|
+
const headers = {};
|
|
203
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
204
|
+
if (typeof v === "string")
|
|
205
|
+
headers[k.toLowerCase()] = v;
|
|
206
|
+
else if (Array.isArray(v))
|
|
207
|
+
headers[k.toLowerCase()] = v.join(", ");
|
|
208
|
+
}
|
|
209
|
+
resolve({ status: res.statusCode ?? 0, headers, bytes, cookies: res.headers["set-cookie"] });
|
|
210
|
+
});
|
|
211
|
+
res.on("error", reject);
|
|
212
|
+
});
|
|
213
|
+
req.on("error", reject);
|
|
214
|
+
if (args.body.length > 0)
|
|
215
|
+
req.write(Buffer.from(args.body));
|
|
216
|
+
req.end();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** Forward without capturing. Callers may separately record the result. */
|
|
220
|
+
export async function forwardHttp(req, host, opts, bodyBytes, label) {
|
|
221
|
+
const url = new URL(req.url);
|
|
222
|
+
const reqLike = { method: req.method.toUpperCase(), path: url.pathname };
|
|
223
|
+
// Credential brokering (static header inject): first matching rule wins.
|
|
224
|
+
// Resolve its `{{secret:REF}}` tokens from the environment secret store.
|
|
225
|
+
const rule = (opts.inject ?? []).find((r) => ruleMatches(r.match, {
|
|
226
|
+
method: reqLike.method,
|
|
227
|
+
path: reqLike.path,
|
|
228
|
+
query: url.searchParams,
|
|
229
|
+
headers: req.headers,
|
|
230
|
+
}));
|
|
231
|
+
const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
|
|
232
|
+
// SigV4 signing: resolve the credential secret refs (direct refs, not
|
|
233
|
+
// `{{secret:}}` templates). Missing required refs fail loud alongside any
|
|
234
|
+
// missing inject refs.
|
|
235
|
+
let signCreds;
|
|
236
|
+
const signMissing = [];
|
|
237
|
+
if (opts.sign?.type === "awsSigv4") {
|
|
238
|
+
const s = opts.sign;
|
|
239
|
+
const accessKeyId = getRecordSecret(s.accessKeyId);
|
|
240
|
+
const secretAccessKey = getRecordSecret(s.secretAccessKey);
|
|
241
|
+
const sessionToken = s.sessionToken ? getRecordSecret(s.sessionToken) : undefined;
|
|
242
|
+
if (accessKeyId === undefined)
|
|
243
|
+
signMissing.push(s.accessKeyId);
|
|
244
|
+
if (secretAccessKey === undefined)
|
|
245
|
+
signMissing.push(s.secretAccessKey);
|
|
246
|
+
if (s.sessionToken && sessionToken === undefined)
|
|
247
|
+
signMissing.push(s.sessionToken);
|
|
248
|
+
if (accessKeyId !== undefined && secretAccessKey !== undefined) {
|
|
249
|
+
signCreds = { accessKeyId, secretAccessKey, sessionToken };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const missing = [...brokered.missing, ...signMissing];
|
|
253
|
+
if (missing.length > 0) {
|
|
254
|
+
const refs = [...new Set(missing)];
|
|
255
|
+
return new Response(`${label}: secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
256
|
+
`(configure them on the project's Secrets page, then start a new environment or fork).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
257
|
+
}
|
|
258
|
+
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
259
|
+
// back into the daemon (the in-VM resolver answers our own gateway for
|
|
260
|
+
// this faked name). See the module header.
|
|
261
|
+
const ips = await resolveExternalA(host);
|
|
262
|
+
if (ips.length === 0) {
|
|
263
|
+
return new Response(`${label}: could not resolve real upstream ${JSON.stringify(host)} ` +
|
|
264
|
+
`via external DNS ${EXTERNAL_DNS} (is the VM online?).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
265
|
+
}
|
|
266
|
+
// Forward headers: drop hop-by-hop/framing, then SET the brokered
|
|
267
|
+
// headers — overwriting whatever the app sent (so the credential can't
|
|
268
|
+
// be smuggled or spoofed by app code). The real values only ever appear
|
|
269
|
+
// on this outbound wire.
|
|
270
|
+
const fwdHeaders = {};
|
|
271
|
+
req.headers.forEach((v, k) => {
|
|
272
|
+
if (k.toLowerCase() === "content-encoding" || !STRIP_FORWARD_HEADERS.has(k.toLowerCase()))
|
|
273
|
+
fwdHeaders[k.toLowerCase()] = v;
|
|
274
|
+
});
|
|
275
|
+
for (const [k, v] of Object.entries(brokered.headers))
|
|
276
|
+
fwdHeaders[k] = v;
|
|
277
|
+
// SigV4 re-signing: strip the app's dummy signature and re-sign the exact
|
|
278
|
+
// outbound request (method/path/query/signed-headers/body-hash) with the
|
|
279
|
+
// brokered credentials, so `authorization`/`x-amz-date`/`x-amz-content-sha256`
|
|
280
|
+
// are internally consistent.
|
|
281
|
+
const signSecrets = [];
|
|
282
|
+
let wireQuery = url.search.slice(1);
|
|
283
|
+
if (opts.sign?.type === "awsSigv4" && signCreds) {
|
|
284
|
+
const scope = inferSigV4Scope({
|
|
285
|
+
authorization: req.headers.get("authorization"),
|
|
286
|
+
credentialParam: url.searchParams.get("X-Amz-Credential"),
|
|
287
|
+
host,
|
|
288
|
+
});
|
|
289
|
+
if (!scope) {
|
|
290
|
+
return new Response(`${label}: could not infer the AWS region/service to sign ` +
|
|
291
|
+
`${reqLike.method} ${reqLike.path} (no SigV4 Credential on the request, and host ` +
|
|
292
|
+
`${JSON.stringify(host)} is not a <service>.<region>.amazonaws.com name).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
293
|
+
}
|
|
294
|
+
// Drop the app's own (dummy-signed) signing headers before re-signing.
|
|
295
|
+
for (const h of ["authorization", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]) {
|
|
296
|
+
delete fwdHeaders[h];
|
|
297
|
+
}
|
|
298
|
+
// Fold the app's remaining `x-amz-*`/content-type headers (e.g.
|
|
299
|
+
// `x-amz-target`) into the signature; they stay on the outbound wire.
|
|
300
|
+
const additionalHeaders = {};
|
|
301
|
+
for (const [k, v] of Object.entries(fwdHeaders)) {
|
|
302
|
+
if (k === "content-type" ||
|
|
303
|
+
(k.startsWith("x-amz-") &&
|
|
304
|
+
k !== "x-amz-date" &&
|
|
305
|
+
k !== "x-amz-content-sha256" &&
|
|
306
|
+
k !== "x-amz-security-token")) {
|
|
307
|
+
additionalHeaders[k] = v;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const forwardParams = stripAmzParams(url.searchParams);
|
|
311
|
+
const signed = signAwsV4Request({
|
|
312
|
+
method: reqLike.method,
|
|
313
|
+
path: reqLike.path,
|
|
314
|
+
query: forwardParams,
|
|
315
|
+
host,
|
|
316
|
+
body: bodyBytes,
|
|
317
|
+
credentials: signCreds,
|
|
318
|
+
scope,
|
|
319
|
+
additionalHeaders,
|
|
320
|
+
});
|
|
321
|
+
for (const [k, v] of Object.entries(signed.headers))
|
|
322
|
+
fwdHeaders[k] = v;
|
|
323
|
+
// Send the canonical query we signed (URLSearchParams' `+`-for-space
|
|
324
|
+
// encoding would diverge from the signed `%20`).
|
|
325
|
+
wireQuery = signed.canonicalQuery;
|
|
326
|
+
signSecrets.push({ ref: opts.sign.accessKeyId, value: signCreds.accessKeyId }, { ref: opts.sign.secretAccessKey, value: signCreds.secretAccessKey });
|
|
327
|
+
if (signCreds.sessionToken && opts.sign.sessionToken) {
|
|
328
|
+
signSecrets.push({ ref: opts.sign.sessionToken, value: signCreds.sessionToken });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const pathWithQuery = reqLike.path + (wireQuery ? `?${wireQuery}` : "");
|
|
332
|
+
let upstream;
|
|
333
|
+
try {
|
|
334
|
+
upstream = await forwardToRealUpstream({
|
|
335
|
+
ip: ips[0],
|
|
336
|
+
host,
|
|
337
|
+
method: reqLike.method,
|
|
338
|
+
pathWithQuery,
|
|
339
|
+
headers: fwdHeaders,
|
|
340
|
+
body: bodyBytes,
|
|
341
|
+
signal: req.signal,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
return new Response(`${label}: forward to real ${host} (${ips[0]}) failed: ${err.message}\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
346
|
+
}
|
|
347
|
+
return { upstream, secrets: [...brokered.secrets, ...signSecrets] };
|
|
348
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { isInteractiveEnvironment } from "../replay-mode.js";
|
|
2
|
+
import { liveFakeHandlers } from "./live-fake.js";
|
|
1
3
|
import { runUninstrumented } from "./instrumentation-scope.js";
|
|
2
4
|
import { bindWebSocketLifetime, getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
3
5
|
import { reportBackgroundFailure } from "./background-failures.js";
|
|
4
6
|
import { parseSubprotocols } from "./ws-bridge.js";
|
|
5
7
|
/** HTTP and WebSocket dispatch share fake state, helpers, and execution scope. */
|
|
6
8
|
export async function dispatchFake(req, server, def, state, ctx) {
|
|
7
|
-
|
|
9
|
+
const implementation = def.live && isInteractiveEnvironment() ? liveFakeHandlers(def) : def;
|
|
10
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket" || !implementation.websocket) {
|
|
8
11
|
try {
|
|
9
|
-
return await runUninstrumented(() =>
|
|
12
|
+
return await runUninstrumented(() => implementation.handler(req, state, ctx));
|
|
10
13
|
}
|
|
11
14
|
catch (err) {
|
|
12
15
|
return fakeError(def.name, err);
|
|
@@ -14,7 +17,7 @@ export async function dispatchFake(req, server, def, state, ctx) {
|
|
|
14
17
|
}
|
|
15
18
|
const lifetime = getWebSocketLifetime(req);
|
|
16
19
|
try {
|
|
17
|
-
const accepted = await runUninstrumented(() =>
|
|
20
|
+
const accepted = await runUninstrumented(() => implementation.websocket(req, state, ctx));
|
|
18
21
|
if (accepted instanceof Response) {
|
|
19
22
|
lifetime.end({ kind: "aborted" });
|
|
20
23
|
return accepted;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { brokerHeaders, forwardHttp, resolveExternalA, ruleMatches, STRIP_FORWARD_HEADERS, } from "../fake-upstream.js";
|
|
2
|
+
import { forwardWebSocket } from "../fake-upstream-websocket.js";
|
|
3
|
+
import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
4
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
5
|
+
import { isInteractiveEnvironment } from "../replay-mode.js";
|
|
6
|
+
/** Select at request time, after the baseline has become interactive. */
|
|
7
|
+
export function liveFakeHandlers(def) {
|
|
8
|
+
function target(req) {
|
|
9
|
+
if (!isInteractiveEnvironment())
|
|
10
|
+
throw new Error("live forwarding requires an interactive environment");
|
|
11
|
+
const url = new URL(req.url);
|
|
12
|
+
if (!def.hostnames.some(host => host.toLowerCase() === url.hostname.toLowerCase())) {
|
|
13
|
+
throw new Error(`fake ${def.name}: upstream hostname is not declared on this fake`);
|
|
14
|
+
}
|
|
15
|
+
return url.hostname;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
async handler(req) {
|
|
19
|
+
const host = target(req);
|
|
20
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
21
|
+
const result = await forwardHttp(req, host, def.live, bytes, `fake(${def.name})`);
|
|
22
|
+
if (result instanceof Response)
|
|
23
|
+
return result;
|
|
24
|
+
const { upstream } = result;
|
|
25
|
+
const headers = new Headers();
|
|
26
|
+
for (const [key, value] of Object.entries(upstream.headers)) {
|
|
27
|
+
if (key !== "set-cookie" && !STRIP_FORWARD_HEADERS.has(key))
|
|
28
|
+
headers.set(key, value);
|
|
29
|
+
}
|
|
30
|
+
for (const cookie of upstream.cookies ?? [])
|
|
31
|
+
headers.append("set-cookie", cookie);
|
|
32
|
+
return new Response(req.method === "HEAD" || [204, 205, 304].includes(upstream.status)
|
|
33
|
+
? null : upstream.bytes, { status: upstream.status, headers });
|
|
34
|
+
},
|
|
35
|
+
async websocket(req) {
|
|
36
|
+
const host = target(req);
|
|
37
|
+
const opts = def.live;
|
|
38
|
+
if (opts.sign)
|
|
39
|
+
throw new Error(`fake(${def.name}): SigV4 WebSocket signing is not supported`);
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
const rule = opts.inject?.find(rule => ruleMatches(rule.match, {
|
|
42
|
+
method: req.method, path: url.pathname, query: url.searchParams, headers: req.headers,
|
|
43
|
+
}));
|
|
44
|
+
const brokered = rule ? brokerHeaders(rule) : { headers: {}, missing: [] };
|
|
45
|
+
if (brokered.missing.length)
|
|
46
|
+
throw new Error(`fake(${def.name}): missing WebSocket secrets ${brokered.missing.join(", ")}`);
|
|
47
|
+
const ips = await resolveExternalA(host);
|
|
48
|
+
if (!ips.length)
|
|
49
|
+
throw new Error(`fake(${def.name}): could not resolve real upstream ${host}`);
|
|
50
|
+
const headers = {};
|
|
51
|
+
req.headers.forEach((value, key) => {
|
|
52
|
+
if (!STRIP_FORWARD_HEADERS.has(key) && !key.startsWith("sec-websocket-"))
|
|
53
|
+
headers[key] = value;
|
|
54
|
+
});
|
|
55
|
+
Object.assign(headers, brokered.headers);
|
|
56
|
+
const { handlers } = await forwardWebSocket({
|
|
57
|
+
url: `wss://${host}${url.pathname}${url.search}`, ip: ips[0], host, headers,
|
|
58
|
+
protocols: (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(p => p.trim()).filter(Boolean),
|
|
59
|
+
label: `fake(${def.name}) WebSocket`, lifetime: getWebSocketLifetime(req),
|
|
60
|
+
finished(_session, error) { if (error)
|
|
61
|
+
reportBackgroundFailure(error); },
|
|
62
|
+
});
|
|
63
|
+
return handlers;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
2
|
+
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
1
3
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
4
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
5
|
import type { Android, AndroidApp } from "./android.js";
|
|
@@ -1456,6 +1458,14 @@ export interface ExecOpts {
|
|
|
1456
1458
|
export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
1457
1459
|
tests: TestCase<unknown, S, F>[];
|
|
1458
1460
|
}
|
|
1461
|
+
/** Forward the entire fake to real HTTPS/WSS upstreams in interactive environments.
|
|
1462
|
+
* Tests and shared baselines always use the scripted handlers. No traffic is recorded. */
|
|
1463
|
+
export interface FakeLiveOptions {
|
|
1464
|
+
/** Same header injection rules as replayFake, applied to HTTP and WebSocket handshakes. */
|
|
1465
|
+
inject?: InjectRule[];
|
|
1466
|
+
/** Re-sign HTTP requests using brokered AWS credentials. Not supported for WebSockets. */
|
|
1467
|
+
sign?: SignConfig;
|
|
1468
|
+
}
|
|
1459
1469
|
/**
|
|
1460
1470
|
* A fake server hosted in the harness. Use this to stand in for
|
|
1461
1471
|
* external HTTP APIs (auth providers, payment gateways, …) that you can't
|
|
@@ -1489,6 +1499,10 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1489
1499
|
* other fakes.
|
|
1490
1500
|
*/
|
|
1491
1501
|
hostnames: readonly string[];
|
|
1502
|
+
/** Opt in to forwarding all HTTP/WebSocket traffic to the declared real hosts
|
|
1503
|
+
* over HTTPS/WSS (port 443) in interactive environments. Errors never fall back
|
|
1504
|
+
* to scripted handlers. Omit to keep the fake scripted in every environment. */
|
|
1505
|
+
live?: FakeLiveOptions;
|
|
1492
1506
|
/** TCP port the fake listens on. Default `80`. */
|
|
1493
1507
|
port?: number;
|
|
1494
1508
|
/** Preserve handler/interceptor CORS policy by default. "permissive"
|
|
@@ -1546,7 +1560,7 @@ export interface FakeDefinition<S = any, H extends Record<string, unknown> = Rec
|
|
|
1546
1560
|
ctx: FakeContext;
|
|
1547
1561
|
}) => H | Promise<H>;
|
|
1548
1562
|
/**
|
|
1549
|
-
* Internal. Platform secret references this fake needs
|
|
1563
|
+
* Internal. Platform secret references this fake needs for upstream access
|
|
1550
1564
|
* (set by {@link replayFake}). The daemon reports the union of these to
|
|
1551
1565
|
* the control plane, which resolves each via its `SecretResolver` and
|
|
1552
1566
|
* supplies them for the environment lifetime, including snapshots. Values
|
package/dist/index.js
CHANGED
|
@@ -407,6 +407,9 @@ export function defineFake(opts) {
|
|
|
407
407
|
throw new Error(`defineFake(${opts.name}): hostname ${JSON.stringify(h)} ends in reserved ".internal" TLD`);
|
|
408
408
|
}
|
|
409
409
|
}
|
|
410
|
+
if (opts.live !== undefined && (!opts.live || typeof opts.live !== "object" || Array.isArray(opts.live))) {
|
|
411
|
+
throw new Error(`defineFake(${opts.name}): \`live\` must be an options object`);
|
|
412
|
+
}
|
|
410
413
|
validateCorsMode(opts.cors);
|
|
411
414
|
if (typeof opts.handler !== "function") {
|
|
412
415
|
throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
|
package/dist/record-secrets.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Replace the complete environment secret set, including removing stale refs. */
|
|
2
2
|
export declare function setRecordSecrets(secrets: Record<string, string>): void;
|
|
3
|
-
/** Resolve a declared project secret for an outbound recording request. */
|
|
3
|
+
/** Resolve a declared project secret for an outbound live or recording request. */
|
|
4
4
|
export declare function getRecordSecret(ref: string): string | undefined;
|
package/dist/record-secrets.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Environment-lifetime credentials for record-mode fakes. The control plane
|
|
1
|
+
// Environment-lifetime credentials for live and record-mode fakes. The control plane
|
|
2
2
|
// resolves declared references from the owning project's Secrets store before
|
|
3
3
|
// service startup and refreshes them on warm starts and interactive forks.
|
|
4
4
|
// They persist across evals, background requests and memory snapshots, just
|
|
5
5
|
// like other environment state. Test forks inherit their parent's credentials;
|
|
6
|
-
//
|
|
6
|
+
// test mode still uses scripted handlers or cassettes and never forwards upstream.
|
|
7
7
|
// Values stay out of project files, config hashes and recorded cassettes.
|
|
8
8
|
const SECRETS = new Map();
|
|
9
9
|
/** Replace the complete environment secret set, including removing stale refs. */
|
|
@@ -12,7 +12,7 @@ export function setRecordSecrets(secrets) {
|
|
|
12
12
|
for (const [ref, value] of Object.entries(secrets))
|
|
13
13
|
SECRETS.set(ref, value);
|
|
14
14
|
}
|
|
15
|
-
/** Resolve a declared project secret for an outbound recording request. */
|
|
15
|
+
/** Resolve a declared project secret for an outbound live or recording request. */
|
|
16
16
|
export function getRecordSecret(ref) {
|
|
17
17
|
return SECRETS.get(ref);
|
|
18
18
|
}
|
package/dist/replay-mode.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export declare function setReplayEnvironmentMode(value: "test" | "interactive"): void;
|
|
2
2
|
export declare function resolveReplayMode(requested?: "auto" | "record" | "replay"): "record" | "replay";
|
|
3
|
+
/** Shared gate for live and replay fakes. Credentials never enable upstream access. */
|
|
4
|
+
export declare function isInteractiveEnvironment(): boolean;
|
package/dist/replay-mode.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Environment state: travels with memory snapshots, independently of the
|
|
2
|
-
// per-case event recorder. Shared/warm baselines must never
|
|
3
|
-
// The control plane enables
|
|
2
|
+
// per-case event recorder. Shared/warm baselines must never forward upstream.
|
|
3
|
+
// The control plane enables upstream access only on an interactive environment,
|
|
4
4
|
// after capturing/restoring the shared baseline and before interactive setup.
|
|
5
5
|
let mode = "test";
|
|
6
6
|
export function setReplayEnvironmentMode(value) {
|
|
@@ -10,3 +10,7 @@ export function resolveReplayMode(requested = "auto") {
|
|
|
10
10
|
// Even an explicit record option cannot send test traffic to real providers.
|
|
11
11
|
return mode === "test" || requested === "replay" ? "replay" : "record";
|
|
12
12
|
}
|
|
13
|
+
/** Shared gate for live and replay fakes. Credentials never enable upstream access. */
|
|
14
|
+
export function isInteractiveEnvironment() {
|
|
15
|
+
return mode === "interactive";
|
|
16
|
+
}
|