feat: Add lint rule enforcing centralized backend HTTP (no-changelog) (#32658)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lorent Lempereur
2026-06-23 13:56:30 +02:00
committed by GitHub
parent 291fd2095c
commit 90f7bf01fb
18 changed files with 975 additions and 106 deletions
@@ -28,15 +28,18 @@ function isLanguageModel(config: unknown): config is LanguageModel {
}
/**
* Fallback proxy `fetch` used only when the caller does not inject one.
* Env-proxy `fetch` fallback for standalone SDK use.
*
* Prefer passing a `fetch` built by `@n8n/backend-network` into
* {@link createModel} / {@link createEmbeddingModel}.
* `@n8n/agents` is a standalone SDK and deliberately does not depend on `@n8n/backend-network`,
* so it cannot build the backend's centrally-guarded transport itself.
* Inside the n8n backend that guarded `fetch` is always injected into {@link createModel} / {@link createEmbeddingModel}
* (see cli's `createAiProxyFetch`, which wraps `@n8n/backend-network`), and this fallback is never reached.
*/
function getProxyFetch(): FetchFn | undefined {
const proxyUrl = process.env.HTTPS_PROXY ?? process.env.HTTP_PROXY;
if (!proxyUrl) return undefined;
// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- standalone SDK cannot depend on @n8n/backend-network; the backend always injects its guarded transport, so this env-proxy path runs only outside the backend (see doc comment above). To drop this: make `fetch` a required arg of createModel/createEmbeddingModel and delete the fallback, so standalone callers always supply their own transport
const { ProxyAgent } = require('undici') as typeof Undici;
const dispatcher = new ProxyAgent(proxyUrl);
return (async (url, init) =>
@@ -1,3 +1,4 @@
/* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */
/**
* Proxy/transport helpers for the AI model suppliers.
*
@@ -1,4 +1,5 @@
import { HumanMessage, type BaseMessage } from '@langchain/core/messages';
// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- axios is the only client exposing manual per-hop redirect control + streaming the cross-host SSRF approval guard needs. To migrate: move this package off undici v6, then route via the factory's getDispatcher() + undici.request({ maxRedirections: 0 }) (see fetchUrl doc)
import axios, { type AxiosRequestConfig } from 'axios';
import type { Readable } from 'node:stream';
+53 -2
View File
@@ -10,6 +10,57 @@ This package consolidates into one place behind a single factory contract:
SSRF/DNS guarding, proxy handling, and the HTTP client plumbing.
The eventual goal is to make backend network behavior reviewable and controllable from a single entry point.
## Roadmap
## Using the factory
Tracked in [CAT-3365](https://linear.app/n8n/issue/CAT-3365/unify-http-concerns-scope-the-backend-network-layer-into-n8nbackend).
Backend code that needs to make an outbound HTTP request should obtain a client
from this package rather than importing an HTTP library directly. That way every
call inherits SSRF/DNS guarding and proxy handling from one place.
Inject the `OutboundHttp` service and pick by intent:
```ts
import { Service } from '@n8n/di';
import { OutboundHttp } from '@n8n/backend-network';
@Service()
export class MyService {
constructor(private readonly http: OutboundHttp) {}
// You make a request and get a response.
async fetchThing() {
return await this.http.requests().request({ url: 'https://api.example.com/thing' });
}
// You hand a guarded transport to a third-party SDK.
makeClient() {
const fetch = this.http.transport().asCustomFetch();
return new SomeSdk({ fetch });
}
}
```
In DI-less code (e.g. task-runner), build the transport directly from the
dependency-free subpath: `import { buildDispatcher } from '@n8n/backend-network/transport'`.
## The boundary rule
The `n8n-local-rules/no-uncentralized-http` ESLint rule enforces this.
It is on by default for every Node backend package.
Two sanctioned escape hatches, depending on the shape of the exception:
**1. Inline disable** When a single callsite legitimately cannot use the factory, disable the
rule on the line with a justifying comment:
```ts
// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- <reason>
import axios from 'axios';
```
Always include the reason after `--`.
**2. Central allow list** For whole packages that are out of scope, add the file
path (a substring of the absolute path is enough) to the `allow` list in
`packages/@n8n/eslint-config/src/configs/backend-network-boundary.ts`.
Keep the list shrinking: every entry is debt or a documented carve-out, not a default.
@@ -1,100 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
// Guards the DI-less bundle: the `@n8n/backend-network/transport` subpath
// must stay free of DI / config / backend-common at runtime, so DI-less callers
// can build transport without dragging the full `OutboundHttp` service and its
// backend dependencies into their bundle.
//
// This walks the *runtime* import graph from `src/transport.ts` (following only
// relative, non-type imports/exports — `import type` / `export type` are erased
// by tsc) and asserts no forbidden package is reachable.
const FORBIDDEN_PACKAGES = ['@n8n/di', '@n8n/backend-common', '@n8n/config', 'cache-manager'];
const ENTRY = resolve(__dirname, '../../transport.ts');
interface ImportRef {
specifier: string;
typeOnly: boolean;
}
/** Extract `import`/`export ... from` specifiers from a source file. */
function parseImports(source: string): ImportRef[] {
const refs: ImportRef[] = [];
// `import ... from '<s>'` / `export ... from '<s>'`. Requiring `from` (and
// disallowing `;` before it) avoids matching value expressions like
// `?? 'env'` inside a function body.
const fromRe = /(?:^|\n)\s*(import|export)(\s+type)?\b[^;]*?\bfrom\s*['"]([^'"]+)['"]/g;
let match: RegExpExecArray | null;
while ((match = fromRe.exec(source)) !== null) {
const [, , typeKeyword, specifier] = match;
refs.push({ specifier, typeOnly: Boolean(typeKeyword) });
}
// Bare side-effect imports: `import '<s>';` (always runtime).
const bareRe = /(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g;
while ((match = bareRe.exec(source)) !== null) {
refs.push({ specifier: match[1], typeOnly: false });
}
return refs;
}
function resolveRelative(fromFile: string, specifier: string): string | undefined {
const base = resolve(dirname(fromFile), specifier);
const candidates = [base, `${base}.ts`, resolve(base, 'index.ts')];
return candidates.find((candidate) => existsSync(candidate) && candidate.endsWith('.ts'));
}
/** All bare (non-relative) specifiers reachable at runtime from the entry file. */
function collectRuntimeExternals(entry: string): Set<string> {
const externals = new Set<string>();
const visited = new Set<string>();
const visit = (file: string) => {
if (visited.has(file)) return;
visited.add(file);
const source = readFileSync(file, 'utf8');
for (const { specifier, typeOnly } of parseImports(source)) {
if (typeOnly) continue; // erased at compile time — no runtime dependency
if (specifier.startsWith('.')) {
const resolved = resolveRelative(file, specifier);
if (resolved) visit(resolved);
continue;
}
externals.add(specifier);
}
};
visit(entry);
return externals;
}
describe('@n8n/backend-network/transport subpath purity', () => {
it('has a resolvable entry file', () => {
expect(existsSync(ENTRY)).toBe(true);
});
it('does not pull DI / config / backend-common into the runtime graph', () => {
const externals = collectRuntimeExternals(ENTRY);
for (const forbidden of FORBIDDEN_PACKAGES) {
const leaked = [...externals].some(
(specifier) => specifier === forbidden || specifier.startsWith(`${forbidden}/`),
);
expect(
leaked,
`forbidden runtime dependency reachable from transport subpath: ${forbidden}`,
).toBe(false);
}
});
it('only depends on undici and n8n-workflow at runtime', () => {
const externals = collectRuntimeExternals(ENTRY);
expect([...externals].sort()).toEqual(['n8n-workflow', 'undici']);
});
});
@@ -0,0 +1,28 @@
import tseslint from 'typescript-eslint';
/**
* Backend network boundary.
*
* Backend outbound HTTP must go through the `@n8n/backend-network` factory so
* SSRF/DNS guarding and proxy handling stay centrally controlled. This turns on
* `n8n-local-rules/no-uncentralized-http` for every Node backend package (it is
* part of `nodeConfig`).
*
* Out of natural scope:
* - Frontend packages (they use `frontendConfig`, not `nodeConfig`)
* - `@n8n/backend-network` itself (it uses `baseConfig`, not `nodeConfig`)
*
* Prefer an inline `// eslint-disable-next-line ... -- <reason>` for a single
* callsite. Use the lists below only for whole-path scope exclusions or tracked
* migration debt. See `packages/@n8n/backend-network/README.md`.
*/
export const backendNetworkBoundaryConfig = tseslint.config({
rules: {
'n8n-local-rules/no-uncentralized-http': [
'error',
{
allow: ['packages/@n8n/benchmark/'],
},
],
},
});
@@ -1,8 +1,9 @@
import tseslint from 'typescript-eslint';
import globals from 'globals';
import { baseConfig } from './base.js';
import { backendNetworkBoundaryConfig } from './backend-network-boundary.js';
export const nodeConfig = tseslint.config(baseConfig, {
export const nodeConfig = tseslint.config(baseConfig, backendNetworkBoundaryConfig, {
languageOptions: {
ecmaVersion: 2024,
globals: globals.node,
@@ -18,6 +18,7 @@ import { NoInternalPackageImportRule } from './no-internal-package-import.js';
import { NoImportEnterpriseEditionRule } from './no-import-enterprise-edition.js';
import { NoTypeOnlyImportInDiRule } from './no-type-only-import-in-di.js';
import { NoErrorInstanceInToThrowRule } from './no-error-instance-in-to-throw.js';
import { NoUncentralizedHttpRule } from './no-uncentralized-http.js';
export const rules = {
'no-uncaught-json-parse': NoUncaughtJsonParseRule,
@@ -39,4 +40,5 @@ export const rules = {
'no-import-enterprise-edition': NoImportEnterpriseEditionRule,
'no-type-only-import-in-di': NoTypeOnlyImportInDiRule,
'no-error-instance-in-to-throw': NoErrorInstanceInToThrowRule,
'no-uncentralized-http': NoUncentralizedHttpRule,
} satisfies Record<string, AnyRuleModule>;
@@ -0,0 +1,193 @@
import { RuleTester, type InvalidTestCase } from '@typescript-eslint/rule-tester';
import { NoUncentralizedHttpRule } from './no-uncentralized-http.js';
type MessageIds = 'useBackendNetwork' | 'addReviewedException';
type Options = [{ allow?: string[] }];
const ruleTester = new RuleTester({
languageOptions: {
parser: require('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
},
});
const runtimeFile = '/repo/packages/cli/src/service.ts';
const REVIEWED_EXCEPTION_COMMENT =
'// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- TODO: explain why @n8n/backend-network cannot be used here';
const exceptionSuggestion = (code: string) => ({
messageId: 'addReviewedException' as const,
output: `${REVIEWED_EXCEPTION_COMMENT}\n${code}`,
});
const withSuggestions = (
cases: Array<InvalidTestCase<MessageIds, Options>>,
): Array<InvalidTestCase<MessageIds, Options>> =>
cases.map((testCase) => ({
...testCase,
errors: testCase.errors.map((error) => ({
...error,
suggestions: [exceptionSuggestion(testCase.code)],
})),
}));
ruleTester.run('no-uncentralized-http', NoUncentralizedHttpRule, {
valid: [
// Type-only imports carry no runtime behavior.
{ code: "import type { AxiosRequestConfig } from 'axios';", filename: runtimeFile },
{ code: "import { type AxiosRequestConfig } from 'axios';", filename: runtimeFile },
{ code: "import type { Dispatcher } from 'undici';", filename: runtimeFile },
// axios error/guard symbols perform no request.
{ code: "import { AxiosError } from 'axios';", filename: runtimeFile },
{ code: "import { isAxiosError, CanceledError } from 'axios';", filename: runtimeFile },
// node http/https server primitives are unaffected; only `Agent` is restricted.
{ code: "import { createServer } from 'node:http';", filename: runtimeFile },
{ code: "import type { Agent } from 'node:https';", filename: runtimeFile },
// Re-exporting a type, or a node-http namespace, carries no request behavior.
{ code: "export type { AxiosRequestConfig } from 'axios';", filename: runtimeFile },
{ code: "export { type Dispatcher } from 'undici';", filename: runtimeFile },
{ code: "export { isAxiosError } from 'axios';", filename: runtimeFile },
{ code: "export * from 'node:http';", filename: runtimeFile },
// Dynamic import / require of unrelated or node-http modules.
{ code: "const http = require('node:http');", filename: runtimeFile },
{ code: "async function f() { await import('node:https'); }", filename: runtimeFile },
{ code: "const lib = require('express');", filename: runtimeFile },
// `import x = require(...)` — namespace binding; node-http parity and unrelated modules pass.
{ code: "import http = require('node:http');", filename: runtimeFile },
{ code: "import express = require('express');", filename: runtimeFile },
// Unrelated modules.
{ code: "import express from 'express';", filename: runtimeFile },
{ code: "import { helper } from './local';", filename: runtimeFile },
// Allow-listed file (reviewed exception).
{
code: "import axios from 'axios';",
filename: '/repo/packages/cli/src/oauth/oauth.service.ts',
options: [{ allow: ['packages/cli/src/oauth/oauth.service.ts'] }],
},
{
code: "import { ProxyAgent } from 'undici';",
filename: '/repo/packages/nodes-base/credentials/foo.ts',
options: [{ allow: ['packages/nodes-base/'] }],
},
// Allow-list substrings (forward-slash) match on Windows backslash paths.
{
code: "import axios from 'axios';",
filename: 'C:\\repo\\packages\\cli\\src\\oauth\\oauth.service.ts',
options: [{ allow: ['packages/cli/src/oauth/oauth.service.ts'] }],
},
// Tests and fixtures import these libraries to mock them, not to call out.
{ code: "import axios from 'axios';", filename: '/repo/packages/cli/src/service.test.ts' },
{
code: "import axios from 'axios';",
filename: '/repo/packages/cli/src/__tests__/service.ts',
},
{
code: "import axios from 'axios';",
filename: '/repo/packages/@n8n/ai-utilities/integration-tests/openai.fixtures.ts',
},
],
invalid: withSuggestions([
{
code: "import axios from 'axios';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'axios' } }],
},
{
code: "import axios, { AxiosError } from 'axios';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import * as axios from 'axios';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import { request } from 'axios';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import { ProxyAgent } from 'undici';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'undici' } }],
},
{
code: "import { Agent } from 'undici';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import { HttpsProxyAgent } from 'https-proxy-agent';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import proxyFromEnv from 'proxy-from-env';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import 'undici';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork' }],
},
{
code: "import { Agent } from 'node:http';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'node:http' } }],
},
// Re-exports pull the client into consumers just like a direct import.
{
code: "export { request } from 'axios';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'axios' } }],
},
{
code: "export * from 'undici';",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'undici' } }],
},
// Dynamic import / require load the whole client at runtime.
{
code: "async function f() { await import('axios'); }",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'axios' } }],
},
{
code: "const { ProxyAgent } = require('undici');",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'undici' } }],
},
// `import x = require('axios')` — TS import-equals form loads the client.
{
code: "import axios = require('axios');",
filename: runtimeFile,
errors: [{ messageId: 'useBackendNetwork', data: { module: 'axios' } }],
},
]).concat([
// Indented callsite: the quick-fix preserves indentation on the inserted line.
{
code: 'function f() {\n\tconst x = require("axios");\n}',
filename: runtimeFile,
errors: [
{
messageId: 'useBackendNetwork',
data: { module: 'axios' },
suggestions: [
{
messageId: 'addReviewedException',
output:
'function f() {\n\t// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- TODO: explain why @n8n/backend-network cannot be used here\n\tconst x = require("axios");\n}',
},
],
},
],
},
]),
});
@@ -0,0 +1,241 @@
import { ESLintUtils, type TSESTree } from '@typescript-eslint/utils';
import { RuleContext } from '@typescript-eslint/utils/ts-eslint';
type Options = [{ allow?: string[] }];
type MessageIds = 'useBackendNetwork' | 'addReviewedException';
const DOCS_URL =
'https://github.com/n8n-io/n8n/blob/master/packages/@n8n/backend-network/README.md';
const RESTRICTED_MODULES = new Set([
'axios',
'undici',
'http-proxy-agent',
'https-proxy-agent',
'proxy-from-env',
]);
const NODE_HTTP_MODULES = new Set(['http', 'https', 'node:http', 'node:https']);
/**
* axios symbols that perform no request
*/
const ALLOWED_AXIOS_VALUE_IMPORTS = new Set([
'AxiosError',
'AxiosHeaders',
'CanceledError',
'isAxiosError',
'isCancel',
]);
const NON_RUNTIME_FILE = /(\.test\.ts|\.spec\.ts|\/__tests__\/|\/test\/|\/integration-tests\/)/;
export const NoUncentralizedHttpRule = ESLintUtils.RuleCreator.withoutDocs<Options, MessageIds>({
meta: {
type: 'problem',
hasSuggestions: true,
docs: {
description:
'Disallow direct backend imports of HTTP client/proxy libraries; outbound HTTP must go through the @n8n/backend-network factory.',
url: DOCS_URL,
},
messages: {
useBackendNetwork:
"Importing '{{ module }}' opens an outbound connection that bypasses n8n's SSRF/DNS guarding and proxy handling. Route it through @n8n/backend-network instead: inject the `OutboundHttp` service, then `.requests()` to send a request or `.transport()` to hand a guarded fetch/dispatcher to an SDK (DI-less code: import from '@n8n/backend-network/transport'). Sanctioned exceptions and the full factory API are in the rule docs.",
addReviewedException:
'Mark this line as a reviewed exception (inserts an eslint-disable with a TODO reason to complete)',
},
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
allow: {
type: 'array',
items: { type: 'string' },
description: 'File path substrings exempt from this rule (reviewed exceptions).',
},
},
},
],
},
defaultOptions: [{ allow: [] }],
create(context, [options]) {
const filename = context.filename.replace(/\\/g, '/');
if (NON_RUNTIME_FILE.test(filename)) {
return {};
}
const allow = options?.allow ?? [];
if (allow.some((entry) => filename.includes(entry))) {
return {};
}
return {
ImportDeclaration(node) {
const module = node.source.value;
if (!RESTRICTED_MODULES.has(module) && !NODE_HTTP_MODULES.has(module)) {
return;
}
if (node.importKind === 'type') {
return;
}
// `import 'undici'`: bare side-effect import still loads the library.
if (RESTRICTED_MODULES.has(module) && node.specifiers.length === 0) {
report(node, module, context);
return;
}
node.specifiers
.filter(
(specifier) => specifier.type !== 'ImportSpecifier' || specifier.importKind !== 'type',
)
.forEach((specifier) => {
const importedName =
specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier'
? specifier.imported.name
: undefined;
reportNamedValue(specifier, module, importedName, context);
});
},
// `export { request } from 'axios'`: re-exporting a value pulls the library into consumers exactly like a direct import.
ExportNamedDeclaration(node) {
if (!node.source) {
// local re-export (`export { x }`), no module.
return;
}
const module = node.source.value;
if (!RESTRICTED_MODULES.has(module) && !NODE_HTTP_MODULES.has(module)) {
return;
}
if (node.exportKind === 'type') {
return;
}
node.specifiers
.filter((specifier) => specifier.exportKind !== 'type')
.forEach((specifier) => {
const importedName =
specifier.local.type === 'Identifier' ? specifier.local.name : undefined;
reportNamedValue(specifier, module, importedName, context);
});
},
// `export * from 'undici'` re-exports the whole client
ExportAllDeclaration(node) {
const module = node.source.value;
if (!RESTRICTED_MODULES.has(module)) {
return;
}
if (node.exportKind === 'type') {
return;
}
report(node, module, context);
},
// Dynamic/runtime module loads (`import()`, `require`, `import =`) only restrict
// RESTRICTED_MODULES, not node:http/https: a runtime load returns the whole module,
// so blocking `Agent` would also block `createServer`, the same accepted gap as the
// namespace/default static import above.
//
// Dynamic `import('axios')` loads the whole client at runtime.
ImportExpression(node) {
if (node.source.type !== 'Literal' || typeof node.source.value !== 'string') {
return;
}
if (!RESTRICTED_MODULES.has(node.source.value)) {
return;
}
report(node, node.source.value, context);
},
// `require('axios')`: same as a dynamic import for our purposes.
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') {
return;
}
const [arg] = node.arguments;
if (!arg || arg.type !== 'Literal' || typeof arg.value !== 'string') {
return;
}
if (!RESTRICTED_MODULES.has(arg.value)) {
return;
}
report(node, arg.value, context);
},
// `import axios = require('axios')`: the TS import-equals form.
TSImportEqualsDeclaration(node) {
if (node.moduleReference.type !== 'TSExternalModuleReference') {
return;
}
const { expression } = node.moduleReference;
if (expression.type !== 'Literal' || typeof expression.value !== 'string') {
return;
}
if (!RESTRICTED_MODULES.has(expression.value)) {
return;
}
report(node, expression.value, context);
},
};
},
});
const REVIEWED_EXCEPTION_COMMENT =
'// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- TODO: explain why @n8n/backend-network cannot be used here';
function report(
node: TSESTree.Node,
module: string,
context: Readonly<RuleContext<MessageIds, Options>>,
): void {
context.report({
node,
messageId: 'useBackendNetwork',
data: { module },
suggest: [
{
messageId: 'addReviewedException',
fix: (fixer) => {
const { line } = node.loc.start;
const indent = /^\s*/.exec(context.sourceCode.lines[line - 1])?.[0] ?? '';
const lineStart = context.sourceCode.getIndexFromLoc({ line, column: 0 });
return fixer.insertTextBeforeRange(
[lineStart, lineStart],
`${indent}${REVIEWED_EXCEPTION_COMMENT}\n`,
);
},
},
],
});
}
function reportNamedValue(
node: TSESTree.Node,
module: string,
importedName: string | undefined,
context: Readonly<RuleContext<MessageIds, Options>>,
) {
if (NODE_HTTP_MODULES.has(module)) {
// Only the raw `Agent` class is restricted from node http/https.
// A namespace/default binding (`import http from 'node:http'`) can still reach `http.Agent`,
// but banning it would also forbid `createServer`; that gap is accepted.
if (importedName === 'Agent') {
report(node, module, context);
}
return;
}
if (module === 'axios' && importedName && ALLOWED_AXIOS_VALUE_IMPORTS.has(importedName)) {
return;
}
report(node, module, context);
}
@@ -21,6 +21,7 @@ import {
type AwsSecurityHeaders,
} from './types';
import { sign } from 'aws4';
// eslint-disable-next-line n8n-local-rules/no-uncentralized-http -- TODO: will be migrated in CAT-3538
import { ProxyAgent } from 'undici';
import { getSystemCredentials } from './system-credentials-utils';
+18
View File
@@ -6,6 +6,7 @@ import { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
import { EndpointScopeCoverageRule } from './rules/endpoint-scope-coverage.rule.js';
import { MigrationTimestampRule } from './rules/migration-timestamp.rule.js';
import { StaleOverridesRule } from './rules/stale-overrides.rule.js';
import { SubpathPurityRule } from './rules/subpath-purity.rule.js';
import { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
export type { CodeHealthContext } from './context.js';
@@ -13,6 +14,8 @@ export { CatalogViolationsRule } from './rules/catalog-violations.rule.js';
export { EndpointScopeCoverageRule } from './rules/endpoint-scope-coverage.rule.js';
export { MigrationTimestampRule } from './rules/migration-timestamp.rule.js';
export { StaleOverridesRule } from './rules/stale-overrides.rule.js';
export { SubpathPurityRule } from './rules/subpath-purity.rule.js';
export type { SubpathSpec } from './rules/subpath-purity.rule.js';
export { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
const defaultRuleSettings: RuleSettingsMap = {
@@ -43,6 +46,20 @@ const defaultRuleSettings: RuleSettingsMap = {
severity: 'warning',
options: { packages: ['packages/cli'] },
},
'subpath-purity': {
enabled: true,
severity: 'error',
options: {
subpaths: [
{
name: '@n8n/backend-network/transport',
entry: 'packages/@n8n/backend-network/src/transport.ts',
forbidden: ['@n8n/di', '@n8n/backend-common', '@n8n/config', 'cache-manager'],
allowedExternals: ['n8n-workflow', 'undici'],
},
],
},
},
};
function mergeSettings(defaults: RuleSettingsMap, overrides?: RuleSettingsMap): RuleSettingsMap {
@@ -65,6 +82,7 @@ export function createDefaultRunner(settings?: RuleSettingsMap): RuleRunner<Code
runner.registerRule(new MigrationTimestampRule());
runner.registerRule(new StaleOverridesRule());
runner.registerRule(new EndpointScopeCoverageRule());
runner.registerRule(new SubpathPurityRule());
runner.applySettings(mergeSettings(defaultRuleSettings, settings));
return runner;
}
@@ -0,0 +1,102 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SubpathPurityRule, type SubpathSpec } from './subpath-purity.rule.js';
import type { CodeHealthContext } from '../context.js';
describe('SubpathPurityRule', () => {
let rootDir: string;
const rule = new SubpathPurityRule();
const write = (relativePath: string, source: string) => {
const abs = path.join(rootDir, relativePath);
mkdirSync(path.dirname(abs), { recursive: true });
writeFileSync(abs, source, 'utf8');
};
const analyze = (subpath: SubpathSpec) => {
rule.configure({ options: { subpaths: [subpath] } });
const context: CodeHealthContext = { rootDir };
return rule.analyze(context);
};
const SPEC: SubpathSpec = {
name: 'pkg/transport',
entry: 'src/transport.ts',
forbidden: ['@n8n/di', '@n8n/config'],
allowedExternals: ['undici', 'n8n-workflow'],
};
beforeEach(() => {
rootDir = mkdtempSync(path.join(tmpdir(), 'subpath-purity-'));
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
it('passes when the runtime graph only reaches allowed externals', () => {
write('src/transport.ts', "export { send } from './client';");
write('src/client.ts', "import { request } from 'undici';\nexport const send = request;");
expect(analyze(SPEC)).toEqual([]);
});
it('flags a forbidden package reached transitively through a value import', () => {
write('src/transport.ts', "export { send } from './client';");
write('src/client.ts', "import { Container } from '@n8n/di';\nexport const send = Container;");
const violations = analyze(SPEC);
expect(violations.map((v) => v.message)).toContainEqual(
expect.stringContaining('forbidden runtime dependency "@n8n/di"'),
);
// Points at the file that actually imports it, not the entry.
expect(violations[0].file).toBe(path.join(rootDir, 'src/client.ts'));
});
it('reports a forbidden package once, not also as an unexpected external', () => {
write('src/transport.ts', "export { send } from './client';");
write('src/client.ts', "import { Container } from '@n8n/di';\nexport const send = Container;");
const messages = analyze(SPEC).map((v) => v.message);
expect(messages).toEqual([expect.stringContaining('forbidden runtime dependency "@n8n/di"')]);
expect(messages).not.toContainEqual(expect.stringContaining('unexpected runtime dependency'));
});
it('ignores a forbidden package imported only as a type', () => {
write('src/transport.ts', "import type { Container } from '@n8n/di';\nexport const x = 1;");
expect(analyze(SPEC)).toEqual([]);
});
it('catches a forbidden package behind a dynamic import', () => {
write('src/transport.ts', "export async function load() { return import('@n8n/config'); }");
const violations = analyze(SPEC);
expect(violations.map((v) => v.message)).toContainEqual(
expect.stringContaining('forbidden runtime dependency "@n8n/config"'),
);
});
it('flags an external outside the allowlist even when not forbidden', () => {
write('src/transport.ts', "import { z } from 'zod';\nexport const schema = z;");
const violations = analyze(SPEC);
expect(violations.map((v) => v.message)).toContainEqual(
expect.stringContaining('unexpected runtime dependency "zod"'),
);
});
it('reports a missing entry file instead of throwing', () => {
const violations = analyze({ ...SPEC, entry: 'src/does-not-exist.ts' });
expect(violations).toHaveLength(1);
expect(violations[0].message).toContain('entry not found');
});
});
@@ -0,0 +1,105 @@
import { BaseRule } from '@n8n/rules-engine';
import type { Violation } from '@n8n/rules-engine';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { CodeHealthContext } from '../context.js';
import { collectRuntimeExternals } from '../utils/import-graph-scanner.js';
/** One DI-less (or otherwise constrained) subpath to keep pure. */
export interface SubpathSpec {
/** Human-readable subpath name, used in messages. */
name: string;
/** Repo-relative path to the subpath's entry source file. */
entry: string;
/** Bare packages that must never be reachable at runtime from `entry`. */
forbidden: string[];
/**
* Optional exact allowlist of runtime externals. When set, any reachable
* bare specifier outside this list is flagged, locking the dependency
* surface, not just the forbidden set.
*/
allowedExternals?: string[];
}
/**
* Guards constrained entry points (e.g. the DI-less `@n8n/backend-network/transport` subpath)
* by walking their runtime import graph and asserting no forbidden package (DI, config, backend-common)
* is reachable, so DI-less callers don't drag the full service into their bundle.
*/
export class SubpathPurityRule extends BaseRule<CodeHealthContext> {
readonly id = 'subpath-purity';
readonly name = 'Subpath Import Purity';
readonly description =
'Constrained entry points must not reach forbidden packages at runtime (keeps DI-less bundles free of DI/config/backend dependencies)';
readonly severity = 'error' as const;
analyze(context: CodeHealthContext): Violation[] {
const subpaths = this.getSubpaths();
return subpaths.flatMap((subpath) => this.analyzeSubpath(context.rootDir, subpath));
}
private getSubpaths(): SubpathSpec[] {
const raw = this.getOptions().subpaths;
return Array.isArray(raw) ? (raw as SubpathSpec[]) : [];
}
private analyzeSubpath(rootDir: string, subpath: SubpathSpec): Violation[] {
const entry = path.resolve(rootDir, subpath.entry);
if (!fs.existsSync(entry)) {
return [
this.createViolation(
entry,
1,
1,
`Subpath "${subpath.name}" entry not found at ${subpath.entry}.`,
'Update the rule options to point at the current entry file, or remove the obsolete subpath spec.',
),
];
}
const externals = collectRuntimeExternals(entry);
const violations: Violation[] = [];
for (const forbidden of subpath.forbidden) {
for (const ref of externals.values()) {
const leaked = ref.specifier === forbidden || ref.specifier.startsWith(`${forbidden}/`);
if (!leaked) continue;
violations.push(
this.createViolation(
ref.file,
ref.line,
1,
`"${subpath.name}" reaches forbidden runtime dependency "${ref.specifier}".`,
`Keep ${forbidden} out of the runtime graph: import it as a type (\`import type\`), move the value behind a DI-aware entry point, or load it lazily so the DI-less subpath stays clean.`,
),
);
}
}
if (subpath.allowedExternals) {
const allowed = new Set(subpath.allowedExternals);
for (const ref of externals.values()) {
if (allowed.has(ref.specifier)) continue;
// Forbidden specifiers are already reported above with a clearer message.
const isForbidden = subpath.forbidden.some(
(forbidden) => ref.specifier === forbidden || ref.specifier.startsWith(`${forbidden}/`),
);
if (!isForbidden) {
violations.push(
this.createViolation(
ref.file,
ref.line,
1,
`"${subpath.name}" pulls in unexpected runtime dependency "${ref.specifier}".`,
`Allowed runtime externals are: ${subpath.allowedExternals.join(', ')}. Add this specifier to the allowlist only if it is genuinely safe for the DI-less subpath.`,
),
);
}
}
}
return violations;
}
}
@@ -0,0 +1,60 @@
import { parseImports } from '@n8n/rules-engine/ast';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Project } from 'ts-morph';
/** Where a bare specifier first enters the runtime graph. */
export interface ExternalRef {
specifier: string;
/** Absolute path of the file that imports it. */
file: string;
line: number;
}
function resolveRelative(fromFile: string, specifier: string): string | undefined {
// Strip a NodeNext `.js`/`.jsx` extension back to its TS source.
const asTs = specifier.replace(/\.jsx?$/, '');
const base = path.resolve(path.dirname(fromFile), asTs);
const candidates = [base, `${base}.ts`, `${base}.tsx`, path.resolve(base, 'index.ts')];
return candidates.find((candidate) => fs.existsSync(candidate) && /\.tsx?$/.test(candidate));
}
/**
* Walk the runtime import graph from `entry`, following only relative,
* non-type imports/exports (`import type` / `export type` are erased by tsc),
* and return every bare (non-relative) specifier reachable at runtime, keyed
* by the first file that imports it.
*/
export function collectRuntimeExternals(entry: string): Map<string, ExternalRef> {
const externals = new Map<string, ExternalRef>();
const visited = new Set<string>();
const project = new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
});
const visit = (file: string) => {
if (visited.has(file)) return;
visited.add(file);
const sourceFile = project.addSourceFileAtPath(file);
for (const { specifier, typeOnly, line } of parseImports(sourceFile)) {
// erased at compile time — no runtime dependency
if (!typeOnly) {
if (specifier.startsWith('.')) {
const resolved = resolveRelative(file, specifier);
if (resolved) {
visit(resolved);
}
continue;
}
if (!externals.has(specifier)) {
externals.set(specifier, { specifier, file, line });
}
}
}
};
visit(entry);
return externals;
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { parseImports } from './imports.js';
import { createInMemoryProject } from './project.js';
describe('parseImports', () => {
const parse = (source: string) => {
const project = createInMemoryProject();
const sourceFile = project.createSourceFile('module.ts', source);
return parseImports(sourceFile).map(({ specifier, typeOnly }) => ({ specifier, typeOnly }));
};
it('treats `import type` / `export type` as erased', () => {
expect(parse("import type { Foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: true },
]);
expect(parse("export type { Foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: true },
]);
});
it('treats inline type-only imports / re-exports as erased', () => {
expect(parse("import { type Foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: true },
]);
expect(parse("export { type Foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: true },
]);
});
it('treats a mixed inline-type import / re-export as runtime', () => {
expect(parse("import { type Foo, bar } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: false },
]);
expect(parse("export { type Foo, bar } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: false },
]);
});
it('treats static value imports and re-exports as runtime', () => {
expect(parse("import { foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: false },
]);
expect(parse("export { foo } from './foo';")).toEqual([
{ specifier: './foo', typeOnly: false },
]);
expect(parse("import './bare';")).toEqual([{ specifier: './bare', typeOnly: false }]);
});
it('treats default and namespace imports as runtime', () => {
expect(parse("import Foo from './foo';")).toEqual([{ specifier: './foo', typeOnly: false }]);
expect(parse("import * as foo from './foo';")).toEqual([
{ specifier: './foo', typeOnly: false },
]);
});
it('ignores a bare `export {}` with no module specifier', () => {
expect(parse('const foo = 1;\nexport { foo };')).toEqual([]);
});
// AST over regex: dynamic forms are runtime dependencies a regex over
// import/export statements would silently miss.
it('catches dynamic import() and require() as runtime', () => {
expect(parse("async function f() { await import('@n8n/di'); }")).toEqual([
{ specifier: '@n8n/di', typeOnly: false },
]);
expect(parse("const di = require('@n8n/di');")).toEqual([
{ specifier: '@n8n/di', typeOnly: false },
]);
});
});
@@ -0,0 +1,89 @@
import { Node, SyntaxKind } from 'ts-morph';
import type { ExportDeclaration, ImportDeclaration, SourceFile } from 'ts-morph';
export interface ImportRef {
/** The module specifier, e.g. `./foo` or `@n8n/di`. */
specifier: string;
/** True when the reference is erased by tsc (`import type` / `export type` / inline `type`). */
typeOnly: boolean;
/** 1-based line of the import/export/call. */
line: number;
}
/** A static `import` is erased only if the whole clause is type-only. */
function importIsTypeOnly(declaration: ImportDeclaration): boolean {
if (declaration.isTypeOnly()) {
return true;
}
// A default or namespace binding always runs at runtime.
if (declaration.getDefaultImport() || declaration.getNamespaceImport()) {
return false;
}
const named = declaration.getNamedImports();
// A bare side-effect import (no clause / no names) runs at runtime.
if (named.length === 0) {
return false;
}
return named.every((element) => element.isTypeOnly());
}
/** An `export ... from` is erased only if the whole clause is type-only. */
function exportIsTypeOnly(declaration: ExportDeclaration): boolean {
if (declaration.isTypeOnly()) {
return true;
}
const named = declaration.getNamedExports();
return named.length > 0 && named.every((element) => element.isTypeOnly());
}
/** Collect dynamic `import('<s>')` and `require('<s>')` — runtime forms a regex would miss. */
function parseDynamicImports(sourceFile: SourceFile): ImportRef[] {
const refs: ImportRef[] = [];
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const expression = call.getExpression();
const isDynamicImport = expression.getKind() === SyntaxKind.ImportKeyword;
const isRequire = Node.isIdentifier(expression) && expression.getText() === 'require';
const [arg] = call.getArguments();
if ((isDynamicImport || isRequire) && arg && Node.isStringLiteral(arg)) {
refs.push({
specifier: arg.getLiteralText(),
typeOnly: false,
line: arg.getStartLineNumber(),
});
}
}
return refs;
}
/**
* Extract module specifiers from a source file via the ts-morph AST. Covers
* static `import`/`export ... from`, bare side-effect imports, and the runtime
* forms a regex would miss: dynamic `import('<s>')` and `require('<s>')`.
*/
export function parseImports(sourceFile: SourceFile): ImportRef[] {
const refs: ImportRef[] = [];
for (const decl of sourceFile.getImportDeclarations()) {
refs.push({
specifier: decl.getModuleSpecifierValue(),
typeOnly: importIsTypeOnly(decl),
line: decl.getStartLineNumber(),
});
}
for (const decl of sourceFile.getExportDeclarations()) {
// Bare `export {}` (no `from`) has no specifier.
const specifier = decl.getModuleSpecifierValue();
if (specifier === undefined) continue;
refs.push({
specifier,
typeOnly: exportIsTypeOnly(decl),
line: decl.getStartLineNumber(),
});
}
refs.push(...parseDynamicImports(sourceFile));
return refs;
}
@@ -2,3 +2,5 @@ export { AstRule } from './ast-rule.js';
export { buildPackageProjects, createInMemoryProject } from './project.js';
export type { AstProjectConfig, PackageProjectSpec, PackageProject } from './project.js';
export { classHasDecorator, getDecoratorByName, getDecoratorObjectFlag } from './decorators.js';
export { parseImports } from './imports.js';
export type { ImportRef } from './imports.js';