@decocms/apps 1.12.0 → 1.12.1
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 +1 -1
- package/vtex/client.ts +22 -4
- package/vtex/utils/authHelpers.ts +7 -14
- package/vtex/utils/cookieSanitizer.ts +173 -0
- package/vtex/utils/cookies.ts +11 -18
package/package.json
CHANGED
package/vtex/client.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
7
|
+
import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
|
|
7
8
|
import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
|
|
8
9
|
import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
|
|
9
10
|
import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
|
|
@@ -276,14 +277,31 @@ export async function vtexCachedFetch<T>(
|
|
|
276
277
|
* This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
|
|
277
278
|
*/
|
|
278
279
|
export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit): Promise<T> {
|
|
279
|
-
// Auto-inject request cookies from RequestContext
|
|
280
|
+
// Auto-inject request cookies from RequestContext.
|
|
281
|
+
//
|
|
282
|
+
// We sanitize the forwarded Cookie header before sending it to VTEX:
|
|
283
|
+
// the janus gateway returns 503 (empty body) on any cookie value that
|
|
284
|
+
// isn't strict ASCII per RFC 6265. Third-party analytics tags that write
|
|
285
|
+
// raw UTF-8 into document.cookie (e.g. category names with accents) will
|
|
286
|
+
// otherwise poison every checkout call for the affected user. The drop
|
|
287
|
+
// report is emitted via warnDroppedCookies() so we have observability the
|
|
288
|
+
// next time a tag misbehaves.
|
|
280
289
|
const existingHeaders = init?.headers as Record<string, string> | undefined;
|
|
281
290
|
if (!existingHeaders?.cookie) {
|
|
282
291
|
const ctx = RequestContext.current;
|
|
283
|
-
const
|
|
284
|
-
if (
|
|
285
|
-
|
|
292
|
+
const raw = ctx?.request.headers.get("cookie");
|
|
293
|
+
if (raw) {
|
|
294
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
|
|
295
|
+
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
|
|
296
|
+
if (cookies) {
|
|
297
|
+
init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
|
|
298
|
+
}
|
|
286
299
|
}
|
|
300
|
+
} else {
|
|
301
|
+
// Caller passed an explicit cookie — sanitize it too.
|
|
302
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(existingHeaders.cookie);
|
|
303
|
+
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
|
|
304
|
+
init = { ...init, headers: { ...existingHeaders, cookie: cookies } };
|
|
287
305
|
}
|
|
288
306
|
|
|
289
307
|
const response = await vtexFetchResponse(path, init);
|
|
@@ -7,27 +7,20 @@
|
|
|
7
7
|
* TanStack Start's Vite plugin only transforms source files.
|
|
8
8
|
*/
|
|
9
9
|
import { getVtexConfig } from "../client";
|
|
10
|
+
import { extractVtexCookies } from "./cookieSanitizer";
|
|
10
11
|
|
|
11
12
|
const DOMAIN_RE = /;\s*domain=[^;]*/gi;
|
|
12
13
|
|
|
13
|
-
const VTEX_COOKIE_PREFIXES = [
|
|
14
|
-
"vtex_session=",
|
|
15
|
-
"vtex_segment=",
|
|
16
|
-
"VtexIdclientAutCookie",
|
|
17
|
-
"checkout.vtex.com",
|
|
18
|
-
"CheckoutOrderFormOwnership",
|
|
19
|
-
];
|
|
20
|
-
|
|
21
14
|
/**
|
|
22
15
|
* Extract VTEX-relevant cookies from a raw Cookie header string.
|
|
23
|
-
*
|
|
16
|
+
*
|
|
17
|
+
* Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES`, plus
|
|
18
|
+
* any cookie whose value contains non-ASCII bytes (which would otherwise
|
|
19
|
+
* make VTEX's janus gateway return 503 Service Unavailable). Both filters
|
|
20
|
+
* live in `./cookieSanitizer` — this is a thin compatibility wrapper.
|
|
24
21
|
*/
|
|
25
22
|
export function extractVtexCookiesFromHeader(raw: string): string {
|
|
26
|
-
return raw
|
|
27
|
-
.split(";")
|
|
28
|
-
.map((c) => c.trim())
|
|
29
|
-
.filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
|
|
30
|
-
.join("; ");
|
|
23
|
+
return extractVtexCookies(raw);
|
|
31
24
|
}
|
|
32
25
|
|
|
33
26
|
/**
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound cookie sanitization for VTEX API calls.
|
|
3
|
+
*
|
|
4
|
+
* VTEX's janus gateway strictly enforces RFC 6265: any cookie value containing
|
|
5
|
+
* non-ASCII bytes causes the gateway to return `503 Service Unavailable` with
|
|
6
|
+
* an empty body, before the request reaches the backing service. This is
|
|
7
|
+
* deterministic — a single poisoned cookie (e.g. an analytics tag writing a
|
|
8
|
+
* category name with accents into `document.cookie` without encoding) can
|
|
9
|
+
* break every checkout call for a user.
|
|
10
|
+
*
|
|
11
|
+
* This module provides two filters:
|
|
12
|
+
*
|
|
13
|
+
* - `sanitizeOutboundCookieHeader()` — drops cookies whose value contains
|
|
14
|
+
* non-ASCII bytes or that look malformed. Default for cookie forwarding
|
|
15
|
+
* in `vtexFetchWithCookies`. Safe to apply to any cookie payload.
|
|
16
|
+
*
|
|
17
|
+
* - `extractVtexCookies()` — allowlist mode. Drops anything that isn't on
|
|
18
|
+
* `VTEX_COOKIE_PREFIXES`. Use when calling endpoints that have no business
|
|
19
|
+
* seeing the user's full cookie soup (e.g. logout, masterdata, profile).
|
|
20
|
+
*
|
|
21
|
+
* The allowlist is the canonical list of cookie name prefixes that VTEX APIs
|
|
22
|
+
* actually consume. It lives here so it's the single source of truth for the
|
|
23
|
+
* package — both `getVtexCookies()` (cookies.ts) and
|
|
24
|
+
* `extractVtexCookiesFromHeader()` (authHelpers.ts) delegate to this module.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cookie name prefixes that are VTEX-relevant and safe to forward to
|
|
29
|
+
* `*.vtexcommercestable.com.br` / `*.myvtex.com` / `secure.<storefront>` APIs.
|
|
30
|
+
*/
|
|
31
|
+
export const VTEX_COOKIE_PREFIXES: readonly string[] = [
|
|
32
|
+
"VtexIdclientAutCookie",
|
|
33
|
+
"checkout.vtex.com",
|
|
34
|
+
"CheckoutOrderFormOwnership",
|
|
35
|
+
"vtex_session",
|
|
36
|
+
"vtex_segment",
|
|
37
|
+
"vtex_is_",
|
|
38
|
+
"janus_sid",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export type DropReason = "non_ascii" | "not_in_allowlist" | "malformed";
|
|
42
|
+
|
|
43
|
+
export interface DroppedCookie {
|
|
44
|
+
name: string;
|
|
45
|
+
reason: DropReason;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CookieSanitizeResult {
|
|
49
|
+
/** Cookie header string ready to forward (may be empty). */
|
|
50
|
+
cookies: string;
|
|
51
|
+
/** Cookies that were filtered out, with the reason per cookie. */
|
|
52
|
+
dropped: DroppedCookie[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CookieSanitizeOptions {
|
|
56
|
+
/**
|
|
57
|
+
* When true, additionally enforces an allowlist: only cookies whose name
|
|
58
|
+
* starts with one of `VTEX_COOKIE_PREFIXES` are kept.
|
|
59
|
+
* @default false
|
|
60
|
+
*/
|
|
61
|
+
allowlist?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cookie pairs are `name=value`, where `name` (token) must be visible ASCII
|
|
66
|
+
* with no separators, and `value` must be ASCII (`0x20–0x7E`) per RFC 6265.
|
|
67
|
+
* We intentionally allow `=` inside the value (some VTEX cookies are
|
|
68
|
+
* URL-encoded JWTs).
|
|
69
|
+
*/
|
|
70
|
+
const TOKEN_RE = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
71
|
+
const ASCII_VALUE_RE = /^[\x20-\x7E]*$/;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Filter a `Cookie:` request header so it's safe to forward to a VTEX origin.
|
|
75
|
+
*
|
|
76
|
+
* Drops:
|
|
77
|
+
* - pairs without `=` (malformed)
|
|
78
|
+
* - pairs whose name isn't a valid HTTP token (malformed)
|
|
79
|
+
* - pairs whose value contains non-ASCII bytes (`non_ascii`)
|
|
80
|
+
* - when `opts.allowlist` is true: pairs not on `VTEX_COOKIE_PREFIXES`
|
|
81
|
+
* (`not_in_allowlist`)
|
|
82
|
+
*
|
|
83
|
+
* Returns the cleaned header plus a per-cookie drop report so callers can
|
|
84
|
+
* log/observe which cookies were removed.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const { cookies, dropped } = sanitizeOutboundCookieHeader(
|
|
89
|
+
* request.headers.get("cookie") ?? "",
|
|
90
|
+
* );
|
|
91
|
+
* if (dropped.length) console.warn("[vtex] dropped cookies", dropped);
|
|
92
|
+
* fetch(vtexUrl, { headers: { cookie: cookies } });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function sanitizeOutboundCookieHeader(
|
|
96
|
+
raw: string,
|
|
97
|
+
opts: CookieSanitizeOptions = {},
|
|
98
|
+
): CookieSanitizeResult {
|
|
99
|
+
if (!raw) return { cookies: "", dropped: [] };
|
|
100
|
+
|
|
101
|
+
const kept: string[] = [];
|
|
102
|
+
const dropped: DroppedCookie[] = [];
|
|
103
|
+
const allowlist = opts.allowlist === true;
|
|
104
|
+
|
|
105
|
+
for (const segment of raw.split(";")) {
|
|
106
|
+
const pair = segment.trim();
|
|
107
|
+
if (!pair) continue;
|
|
108
|
+
|
|
109
|
+
const eq = pair.indexOf("=");
|
|
110
|
+
if (eq <= 0) {
|
|
111
|
+
dropped.push({ name: pair.slice(0, 32), reason: "malformed" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const name = pair.slice(0, eq);
|
|
116
|
+
const value = pair.slice(eq + 1);
|
|
117
|
+
|
|
118
|
+
if (!TOKEN_RE.test(name)) {
|
|
119
|
+
dropped.push({ name, reason: "malformed" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!ASCII_VALUE_RE.test(value)) {
|
|
124
|
+
dropped.push({ name, reason: "non_ascii" });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (allowlist && !VTEX_COOKIE_PREFIXES.some((p) => name.startsWith(p))) {
|
|
129
|
+
dropped.push({ name, reason: "not_in_allowlist" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
kept.push(`${name}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { cookies: kept.join("; "), dropped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Allowlist convenience wrapper — keep only VTEX-prefixed, ASCII-clean cookies.
|
|
141
|
+
*
|
|
142
|
+
* Equivalent to `sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies`.
|
|
143
|
+
*/
|
|
144
|
+
export function extractVtexCookies(raw: string): string {
|
|
145
|
+
return sanitizeOutboundCookieHeader(raw, { allowlist: true }).cookies;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Track which cookie names we've already warned about to avoid spamming
|
|
150
|
+
* the worker logs. Process-scoped — Cloudflare Workers reset this on each
|
|
151
|
+
* isolate restart, which is exactly the cadence we want.
|
|
152
|
+
*/
|
|
153
|
+
const _warned = new Set<string>();
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Emit a structured `console.warn` for each dropped cookie, deduped by
|
|
157
|
+
* `name+reason` for the lifetime of the worker isolate. Call sites pass an
|
|
158
|
+
* arbitrary `host` label so we can correlate in logs.
|
|
159
|
+
*/
|
|
160
|
+
export function warnDroppedCookies(dropped: DroppedCookie[], host: string): void {
|
|
161
|
+
if (dropped.length === 0) return;
|
|
162
|
+
for (const d of dropped) {
|
|
163
|
+
const key = `${host}::${d.name}::${d.reason}`;
|
|
164
|
+
if (_warned.has(key)) continue;
|
|
165
|
+
_warned.add(key);
|
|
166
|
+
console.warn(`[vtex.cookie.dropped] host=${host} name=${d.name} reason=${d.reason}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Reset the dedup set — exposed for tests. */
|
|
171
|
+
export function _resetCookieWarnDedupForTests(): void {
|
|
172
|
+
_warned.clear();
|
|
173
|
+
}
|
package/vtex/utils/cookies.ts
CHANGED
|
@@ -121,29 +121,22 @@ export const proxySetCookie = (from: Headers, to: Headers, toDomain?: URL | stri
|
|
|
121
121
|
export const CHECKOUT_DATA_ACCESS_COOKIE = "CheckoutDataAccess";
|
|
122
122
|
export const VTEX_CHKO_AUTH = "Vtex_CHKO_Auth";
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
"VtexIdclientAutCookie",
|
|
131
|
-
"checkout.vtex",
|
|
132
|
-
"CheckoutOrderFormOwnership",
|
|
133
|
-
"vtex_is_",
|
|
134
|
-
];
|
|
124
|
+
// Re-export the canonical allowlist from cookieSanitizer so consumers that
|
|
125
|
+
// previously imported it from this module keep working. The single source
|
|
126
|
+
// of truth lives in cookieSanitizer.ts.
|
|
127
|
+
export { VTEX_COOKIE_PREFIXES } from "./cookieSanitizer";
|
|
128
|
+
|
|
129
|
+
import { extractVtexCookies } from "./cookieSanitizer";
|
|
135
130
|
|
|
136
131
|
/**
|
|
137
132
|
* Filter a request's cookies to only VTEX-relevant ones.
|
|
138
|
-
*
|
|
133
|
+
*
|
|
134
|
+
* Strict allowlist: drops any cookie not on `VTEX_COOKIE_PREFIXES` plus any
|
|
135
|
+
* cookie whose value contains non-ASCII bytes (which would make VTEX's
|
|
136
|
+
* janus gateway return 503).
|
|
139
137
|
*/
|
|
140
138
|
export function getVtexCookies(request: Request): string {
|
|
141
|
-
|
|
142
|
-
return raw
|
|
143
|
-
.split(";")
|
|
144
|
-
.map((c) => c.trim())
|
|
145
|
-
.filter((c) => VTEX_COOKIE_PREFIXES.some((p) => c.startsWith(p)))
|
|
146
|
-
.join("; ");
|
|
139
|
+
return extractVtexCookies(request.headers.get("cookie") ?? "");
|
|
147
140
|
}
|
|
148
141
|
|
|
149
142
|
/**
|