@unito/integration-sdk
Version:
Integration SDK
143 lines (134 loc) • 5.12 kB
text/typescript
/**
* Inside a `catch` block in a handler or route, do NOT propagate the provider's
* `error.message` (or `err.message`, `e.message`, etc.) into the message
* argument of an `HttpErrors.*` throw. The provider's error string crosses the
* HTTP boundary into Unito's response, where it can carry tokens, customer
* PII, or internal field names back to upstream callers and logs.
*
* Bad:
* } catch (error) {
* throw new HttpErrors.BadRequestError(error.message);
* throw new HttpErrors.X(`upload failed: ${err.message}`);
* throw new HttpErrors.Y(error instanceof Error ? error.message : String(error));
* }
*
* Good — strip provider message, surface a stable summary instead:
* } catch (error) {
* context.logger.warn('upload failed', { error: error.message });
* throw new HttpErrors.UnprocessableEntityError('Upload failed');
* }
*
* Scope:
* Fires only in `**\/src/handlers/**` and `**\/src/routes/**`. Rate-limiter
* files under those paths are excluded (intentional internal-lib message
* rethrow on rate-limit responses).
*/
import type { Rule } from 'eslint';
import type * as ESTree from 'estree';
import { isInsideCatchClause, isHttpErrorsCallee } from '../utils.js';
const SCOPE_FILE_RE = /\/src\/(handlers|routes)\//;
const RATE_LIMITER_RE = /[Rr]ate[Ll]imit/;
const ERROR_IDENTIFIER_RE = /^(err|error|e|originalError|cause)$/i;
function getPropertyName(property: ESTree.Node | undefined | null): string | null {
if (!property) {
return null;
}
if (property.type === 'Identifier') {
return property.name;
}
if (property.type === 'Literal' && typeof property.value === 'string') {
return property.value;
}
return null;
}
function isErrorMessageAccess(memberExpression: ESTree.Node): boolean {
if (memberExpression.type !== 'MemberExpression') {
return false;
}
if (getPropertyName(memberExpression.property) !== 'message') {
return false;
}
if (memberExpression.object.type !== 'Identifier') {
return false;
}
return ERROR_IDENTIFIER_RE.test(memberExpression.object.name);
}
function containsErrorMessageAccess(node: ESTree.Node | null | undefined): boolean {
if (!node) {
return false;
}
if (node.type === 'MemberExpression') {
if (isErrorMessageAccess(node)) {
return true;
}
return containsErrorMessageAccess(node.object) || containsErrorMessageAccess(node.property);
}
if (node.type === 'TemplateLiteral') {
return node.expressions.some(e => containsErrorMessageAccess(e));
}
if (node.type === 'ConditionalExpression') {
return (
containsErrorMessageAccess(node.test) ||
containsErrorMessageAccess(node.consequent) ||
containsErrorMessageAccess(node.alternate)
);
}
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') {
return containsErrorMessageAccess(node.left) || containsErrorMessageAccess(node.right);
}
if (node.type === 'CallExpression' || node.type === 'NewExpression') {
return node.arguments.some(arg => containsErrorMessageAccess(arg as ESTree.Node));
}
// TS-specific node types (cast / non-null assertion). `estree` doesn't declare
// them but @typescript-eslint/parser emits them; treat them as transparent
// wrappers and recurse on .expression.
const ts = node as unknown as { type: string; expression?: ESTree.Node };
if (ts.type === 'TSAsExpression' || ts.type === 'TSNonNullExpression') {
return containsErrorMessageAccess(ts.expression);
}
return false;
}
const rule: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description:
'Disallow rethrowing the provider error message into the HTTP response (`HttpErrors.X(err.message)`); strip the provider message and throw a stable summary across the boundary',
recommended: true,
},
schema: [],
messages: {
errorMessageRethrow:
'Do not propagate `error.message` (or `err.message`/`e.message`) into a `HttpErrors.*` throw. Provider error strings can carry tokens, PII, or internal field names — rethrowing them across the HTTP boundary turns the connector into an exfiltration path. Log the original message via the structured logger (key-based redaction) and throw a stable, provider-agnostic summary instead.',
},
},
create(context) {
const filename = context.filename ?? '';
if (!SCOPE_FILE_RE.test(filename)) {
return {};
}
if (RATE_LIMITER_RE.test(filename)) {
return {};
}
return {
ThrowStatement(node) {
const argument = node.argument;
if (!argument || argument.type !== 'NewExpression') {
return;
}
if (!isHttpErrorsCallee(argument.callee)) {
return;
}
if (!isInsideCatchClause(node as Rule.Node)) {
return;
}
const propagates = argument.arguments.some(arg => containsErrorMessageAccess(arg as ESTree.Node));
if (!propagates) {
return;
}
context.report({ node, messageId: 'errorMessageRethrow' });
},
};
},
};
export default rule;