@vtex/api 6.52.5 → 6.52.6-beta.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/CHANGELOG.md +13 -0
- package/lib/service/worker/runtime/http/middlewares/error.js +11 -4
- package/lib/service/worker/runtime/http/middlewares/sanitizeResponse.d.ts +1 -1
- package/lib/service/worker/runtime/http/middlewares/sanitizeResponse.js +18 -11
- package/lib/utils/responseCredentials.d.ts +19 -0
- package/lib/utils/responseCredentials.js +98 -0
- package/lib/utils/stringRedactor.d.ts +1 -0
- package/lib/utils/stringRedactor.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- Redact credential headers and serialized credentials inside JSON responses, including REST
|
|
13
|
+
Axios objects and GraphQL error messages. Covers `proxy-authorization`, `x-vtex-api-appkey`
|
|
14
|
+
and `x-vtex-api-apptoken`, case-insensitively, using the same patterns as string bodies.
|
|
15
|
+
- Preserve the existing inspection-limit behavior: send unvisited values unchanged and report
|
|
16
|
+
`limit_reached`, retaining redactions already completed. Uninspectable custom serialization
|
|
17
|
+
still withholds the response. Buffers and streams remain outside
|
|
18
|
+
the JSON sanitizer; apps must not put credentials in them. Apps must adopt the updated library
|
|
19
|
+
with `VTEX_IO_RESPONSE_SANITIZER` enabled to receive this protection. Unknown credentials
|
|
20
|
+
represented as name/value header pairs are outside this protection.
|
|
21
|
+
- Report fixed inspection failure categories when a response is withheld, without logging its content.
|
|
22
|
+
|
|
10
23
|
## [6.52.5]
|
|
11
24
|
|
|
12
25
|
### Security
|
|
@@ -5,6 +5,7 @@ const constants_1 = require("../../../../../constants");
|
|
|
5
5
|
const RequestCancelledError_1 = require("../../../../../errors/RequestCancelledError");
|
|
6
6
|
const TooManyRequestsError_1 = require("../../../../../errors/TooManyRequestsError");
|
|
7
7
|
const error_1 = require("../../../../../utils/error");
|
|
8
|
+
const responseCredentials_1 = require("../../../../../utils/responseCredentials");
|
|
8
9
|
const responseSanitizer_1 = require("../../../../../utils/responseSanitizer");
|
|
9
10
|
const logger_1 = require("../../../../logger");
|
|
10
11
|
const sanitizeResponse_1 = require("./sanitizeResponse");
|
|
@@ -51,12 +52,18 @@ const logAndRemoveSensitiveData = (ctx, err) => {
|
|
|
51
52
|
// bodies the app sets, so both paths expose the same: `cleanError` alone removes credentials only
|
|
52
53
|
// at the top level, and by a short list of header names, so an error that is a property of the
|
|
53
54
|
// thrown value (`originalError`) kept its config, request and stack.
|
|
54
|
-
const buildResponseBody = (thrown) => {
|
|
55
|
+
const buildResponseBody = (thrown, ctx) => {
|
|
56
|
+
var _a;
|
|
55
57
|
try {
|
|
56
|
-
|
|
58
|
+
const sanitized = (0, responseSanitizer_1.sanitizeErrors)(thrown, { linked: constants_1.LINKED });
|
|
59
|
+
const { authToken, adminUserAuthToken, storeUserAuthToken } = (_a = ctx.vtex) !== null && _a !== void 0 ? _a : {};
|
|
60
|
+
return (0, responseCredentials_1.sanitizeCredentials)((0, error_1.cleanError)(sanitized.value), [authToken, adminUserAuthToken, storeUserAuthToken]).value;
|
|
57
61
|
}
|
|
58
62
|
catch (sanitizeError) {
|
|
59
|
-
console.error('[node-vtex-api error] the thrown value could not be sanitized',
|
|
63
|
+
console.error('[node-vtex-api error] the thrown value could not be sanitized', {
|
|
64
|
+
errorName: sanitizeError === null || sanitizeError === void 0 ? void 0 : sanitizeError.name,
|
|
65
|
+
inspectionReason: (0, responseCredentials_1.inspectionFailureReason)(sanitizeError),
|
|
66
|
+
});
|
|
60
67
|
return sanitizeResponse_1.WITHHELD_BODY;
|
|
61
68
|
}
|
|
62
69
|
};
|
|
@@ -97,7 +104,7 @@ async function error(ctx, next) {
|
|
|
97
104
|
// `err` is what is logged, which keeps the config and the request to debug with. It is not the
|
|
98
105
|
// body: the sanitized value is.
|
|
99
106
|
logAndRemoveSensitiveData(ctx, err);
|
|
100
|
-
ctx.body = ctx.body || buildResponseBody(e);
|
|
107
|
+
ctx.body = ctx.body || buildResponseBody(e, ctx);
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
exports.error = error;
|
|
@@ -29,7 +29,7 @@ export declare enum SanitizerReason {
|
|
|
29
29
|
*
|
|
30
30
|
* The body is also sanitized when the downstream handlers throw after having set it, as the error
|
|
31
31
|
* middleware keeps an existing body. If the sanitizer itself fails the body is withheld. If it
|
|
32
|
-
*
|
|
32
|
+
* reaches an inspection limit, unvisited values are sent unchanged and the limit is reported. It never throws.
|
|
33
33
|
*
|
|
34
34
|
* It is a backstop for apps that return errors by mistake. It is not a boundary against an app that
|
|
35
35
|
* wants to disclose its own credentials, which has them.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sanitizeResponse = exports.SanitizerReason = exports.WITHHELD_BODY = exports.RESPONSE_SANITIZER_SWITCH = void 0;
|
|
4
4
|
const constants_1 = require("../../../../../constants");
|
|
5
|
+
const responseCredentials_1 = require("../../../../../utils/responseCredentials");
|
|
5
6
|
const responseSanitizer_1 = require("../../../../../utils/responseSanitizer");
|
|
6
7
|
const stringRedactor_1 = require("../../../../../utils/stringRedactor");
|
|
7
8
|
/**
|
|
@@ -72,6 +73,7 @@ const redactStringBody = (ctx, body) => {
|
|
|
72
73
|
warn(ctx, SanitizerReason.SecretRemoved, { redactions: redacted });
|
|
73
74
|
};
|
|
74
75
|
const sanitizeCtxBody = (ctx) => {
|
|
76
|
+
var _a;
|
|
75
77
|
try {
|
|
76
78
|
if (process.env[exports.RESPONSE_SANITIZER_SWITCH] === 'off') {
|
|
77
79
|
return;
|
|
@@ -87,13 +89,13 @@ const sanitizeCtxBody = (ctx) => {
|
|
|
87
89
|
// Most bodies have no error. A scan decides that without the copying walk; the walk runs only
|
|
88
90
|
// once an error has been found, or the scan could not finish.
|
|
89
91
|
const scan = (0, responseSanitizer_1.scanForErrors)(body);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
const sanitized = scan === 'clean'
|
|
93
|
+
? { value: body, replaced: 0, truncated: false }
|
|
94
|
+
: (0, responseSanitizer_1.sanitizeErrors)(body, { linked: constants_1.LINKED });
|
|
95
|
+
const { replaced, truncated } = sanitized;
|
|
96
|
+
const { authToken, adminUserAuthToken, storeUserAuthToken } = (_a = ctx.vtex) !== null && _a !== void 0 ? _a : {};
|
|
97
|
+
const { value, redacted, truncated: credentialsTruncated } = (0, responseCredentials_1.sanitizeCredentials)(sanitized.value, [authToken, adminUserAuthToken, storeUserAuthToken]);
|
|
98
|
+
if (truncated || credentialsTruncated) {
|
|
97
99
|
warn(ctx, SanitizerReason.LimitReached);
|
|
98
100
|
}
|
|
99
101
|
if (value === body) {
|
|
@@ -106,12 +108,17 @@ const sanitizeCtxBody = (ctx) => {
|
|
|
106
108
|
if (contentType) {
|
|
107
109
|
ctx.set('Content-Type', contentType);
|
|
108
110
|
}
|
|
109
|
-
|
|
111
|
+
if (replaced > 0) {
|
|
112
|
+
warn(ctx, SanitizerReason.ErrorRemoved, { errors: replaced });
|
|
113
|
+
}
|
|
114
|
+
if (redacted > 0) {
|
|
115
|
+
warn(ctx, SanitizerReason.SecretRemoved, { redactions: redacted });
|
|
116
|
+
}
|
|
110
117
|
}
|
|
111
118
|
catch (err) {
|
|
112
|
-
//
|
|
119
|
+
// Withhold on inspection failures; reaching a traversal budget above still preserves the response.
|
|
113
120
|
withholdBody(ctx);
|
|
114
|
-
warn(ctx, SanitizerReason.Failed, { errorName: errorName(err) });
|
|
121
|
+
warn(ctx, SanitizerReason.Failed, { errorName: errorName(err), inspectionReason: (0, responseCredentials_1.inspectionFailureReason)(err) });
|
|
115
122
|
}
|
|
116
123
|
};
|
|
117
124
|
/**
|
|
@@ -127,7 +134,7 @@ const sanitizeCtxBody = (ctx) => {
|
|
|
127
134
|
*
|
|
128
135
|
* The body is also sanitized when the downstream handlers throw after having set it, as the error
|
|
129
136
|
* middleware keeps an existing body. If the sanitizer itself fails the body is withheld. If it
|
|
130
|
-
*
|
|
137
|
+
* reaches an inspection limit, unvisited values are sent unchanged and the limit is reported. It never throws.
|
|
131
138
|
*
|
|
132
139
|
* It is a backstop for apps that return errors by mistake. It is not a boundary against an app that
|
|
133
140
|
* wants to disclose its own credentials, which has them.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
type InspectionReason = 'cycle' | 'custom_serialization' | 'custom_date_serialization' | 'unsupported_object' | 'accessor';
|
|
2
|
+
export declare class ResponseInspectionError extends Error {
|
|
3
|
+
readonly reason: InspectionReason;
|
|
4
|
+
constructor(reason: InspectionReason);
|
|
5
|
+
}
|
|
6
|
+
export declare const inspectionFailureReason: (error: unknown) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Copy-on-write redaction for JSON response values, including already serialized Axios errors
|
|
9
|
+
* and GraphQL messages. Does not mutate cached/frozen values. Limits preserve unvisited values;
|
|
10
|
+
* unexpected inspection failures still throw.
|
|
11
|
+
* Unknown secrets in name/value header pairs are not recognized as credential fields.
|
|
12
|
+
* Buffers and streams are handled separately by the HTTP middleware and are outside this contract.
|
|
13
|
+
*/
|
|
14
|
+
export declare const sanitizeCredentials: <T>(body: T, secrets?: Array<string | undefined>) => {
|
|
15
|
+
redacted: number;
|
|
16
|
+
truncated: boolean;
|
|
17
|
+
value: T;
|
|
18
|
+
};
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeCredentials = exports.inspectionFailureReason = exports.ResponseInspectionError = void 0;
|
|
4
|
+
const stringRedactor_1 = require("./stringRedactor");
|
|
5
|
+
// Bound work before serializing a response. Preserve the existing limit behavior: leave
|
|
6
|
+
// unvisited values untouched and report truncation instead of withholding the response.
|
|
7
|
+
const MAX_DEPTH = 32;
|
|
8
|
+
const MAX_VALUES = 5000000;
|
|
9
|
+
const MAX_TEXT_LENGTH = 16 * 1024 * 1024;
|
|
10
|
+
class ResponseInspectionError extends Error {
|
|
11
|
+
constructor(reason) {
|
|
12
|
+
super('Response inspection failed');
|
|
13
|
+
this.reason = reason;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.ResponseInspectionError = ResponseInspectionError;
|
|
17
|
+
// Fixed categories only: never include response values or arbitrary exception messages in diagnostics.
|
|
18
|
+
const inspectionFailureReason = (error) => error instanceof ResponseInspectionError ? error.reason : 'unexpected_failure';
|
|
19
|
+
exports.inspectionFailureReason = inspectionFailureReason;
|
|
20
|
+
/**
|
|
21
|
+
* Copy-on-write redaction for JSON response values, including already serialized Axios errors
|
|
22
|
+
* and GraphQL messages. Does not mutate cached/frozen values. Limits preserve unvisited values;
|
|
23
|
+
* unexpected inspection failures still throw.
|
|
24
|
+
* Unknown secrets in name/value header pairs are not recognized as credential fields.
|
|
25
|
+
* Buffers and streams are handled separately by the HTTP middleware and are outside this contract.
|
|
26
|
+
*/
|
|
27
|
+
const sanitizeCredentials = (body, secrets = []) => {
|
|
28
|
+
let values = 0;
|
|
29
|
+
let textLength = 0;
|
|
30
|
+
let redacted = 0;
|
|
31
|
+
let truncated = false;
|
|
32
|
+
const ancestors = new Set();
|
|
33
|
+
const visit = (value, depth) => {
|
|
34
|
+
if (++values > MAX_VALUES || depth > MAX_DEPTH) {
|
|
35
|
+
truncated = true;
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
textLength += value.length;
|
|
40
|
+
if (textLength > MAX_TEXT_LENGTH) {
|
|
41
|
+
truncated = true;
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
const redaction = (0, stringRedactor_1.redactSecrets)(value, secrets);
|
|
45
|
+
redacted += redaction.redacted;
|
|
46
|
+
return redaction.value;
|
|
47
|
+
}
|
|
48
|
+
if (value === null || typeof value !== 'object') {
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
// Dates serialize to a credential-free ISO string. Other custom serialization cannot be
|
|
52
|
+
// inspected reliably without executing it; withhold it instead of sending unchecked output.
|
|
53
|
+
if (value instanceof Date) {
|
|
54
|
+
if (value.toJSON !== Date.prototype.toJSON) {
|
|
55
|
+
throw new ResponseInspectionError('custom_date_serialization');
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (ancestors.has(value)) {
|
|
60
|
+
throw new ResponseInspectionError('cycle');
|
|
61
|
+
}
|
|
62
|
+
if (typeof value.toJSON === 'function') {
|
|
63
|
+
throw new ResponseInspectionError('custom_serialization');
|
|
64
|
+
}
|
|
65
|
+
const proto = Object.getPrototypeOf(value);
|
|
66
|
+
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) {
|
|
67
|
+
throw new ResponseInspectionError('unsupported_object');
|
|
68
|
+
}
|
|
69
|
+
const keys = Object.keys(value);
|
|
70
|
+
if (values + keys.length > MAX_VALUES) {
|
|
71
|
+
truncated = true;
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
ancestors.add(value);
|
|
75
|
+
let result = value;
|
|
76
|
+
for (const key of keys) {
|
|
77
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
78
|
+
if (!('value' in descriptor)) {
|
|
79
|
+
throw new ResponseInspectionError('accessor');
|
|
80
|
+
}
|
|
81
|
+
const child = descriptor.value;
|
|
82
|
+
const sensitive = (0, stringRedactor_1.isSensitiveKey)(key) && child != null && child !== stringRedactor_1.REDACTED;
|
|
83
|
+
const safe = sensitive ? stringRedactor_1.REDACTED : visit(child, depth + 1);
|
|
84
|
+
if (sensitive) {
|
|
85
|
+
redacted++;
|
|
86
|
+
}
|
|
87
|
+
if (safe !== child) {
|
|
88
|
+
result = result === value ? (Array.isArray(value) ? value.slice() : { ...value }) : result;
|
|
89
|
+
Object.defineProperty(result, key, { configurable: true, enumerable: true, value: safe, writable: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
ancestors.delete(value);
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
const sanitized = visit(body, 0);
|
|
96
|
+
return { redacted, truncated, value: sanitized };
|
|
97
|
+
};
|
|
98
|
+
exports.sanitizeCredentials = sanitizeCredentials;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.redactSecrets = exports.REDACTED = void 0;
|
|
3
|
+
exports.redactSecrets = exports.isSensitiveKey = exports.REDACTED = void 0;
|
|
4
4
|
exports.REDACTED = '[Redacted]';
|
|
5
5
|
// Shorter values are not credentials, and replacing them everywhere would damage ordinary text.
|
|
6
6
|
const MIN_SECRET_LENGTH = 16;
|
|
@@ -18,6 +18,9 @@ const SENSITIVE_KEYS = [
|
|
|
18
18
|
'x-vtex-credential',
|
|
19
19
|
'x-vtex-session',
|
|
20
20
|
];
|
|
21
|
+
const sensitiveKeys = new Set(SENSITIVE_KEYS);
|
|
22
|
+
const isSensitiveKey = (key) => sensitiveKeys.has(key.toLowerCase());
|
|
23
|
+
exports.isSensitiveKey = isSensitiveKey;
|
|
21
24
|
// `"key":"value"`, also when the text was serialized twice (`\"key\":\"value\"`). Captures
|
|
22
25
|
// everything up to the value, which is what is replaced. The value is a run of characters that are
|
|
23
26
|
// not a quote, where a backslash only escapes the character after it. The two alternatives of the
|