From f7250f426c361fdfaf9c4e01714e590e99bde81d Mon Sep 17 00:00:00 2001 From: Adam Sobaniec Date: Mon, 13 Jul 2026 11:49:20 +0200 Subject: [PATCH 1/6] chore: handling loopback address - preventing security report --- .changeset/loopback-drift-transport.md | 5 + .../commands/drift/security-rule.test.ts | 137 ++++++++++++++++++ packages/cli/src/commands/drift/README.md | 3 + .../commands/drift/rules/builtins/security.ts | 35 ++++- 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 .changeset/loopback-drift-transport.md create mode 100644 packages/cli/src/__tests__/commands/drift/security-rule.test.ts diff --git a/.changeset/loopback-drift-transport.md b/.changeset/loopback-drift-transport.md new file mode 100644 index 0000000000..d0a735161f --- /dev/null +++ b/.changeset/loopback-drift-transport.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': patch +--- + +Fixed the `drift` command's `security-baseline` rule reporting false-positive "credential exposure over insecure HTTP transport" warnings for traffic captured against loopback hosts (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`). Loopback traffic never leaves the machine, matching the W3C Secure Contexts definition of trustworthy origins, so sandboxed recordings — for example from `redocly proxy` in front of a local target during e2e runs — no longer produce transport warnings. diff --git a/packages/cli/src/__tests__/commands/drift/security-rule.test.ts b/packages/cli/src/__tests__/commands/drift/security-rule.test.ts new file mode 100644 index 0000000000..db56ccee99 --- /dev/null +++ b/packages/cli/src/__tests__/commands/drift/security-rule.test.ts @@ -0,0 +1,137 @@ +import { SecurityRule } from '../../../commands/drift/rules/builtins/security.js'; +import type { + MatchedOperation, + NormalizedExchange, + RuleContext, +} from '../../../commands/drift/types/index.js'; + +function createMatchedOperation(): MatchedOperation { + return { + operation: { + operationId: 'listMenuItems', + method: 'get', + pathTemplate: '/menu', + pathRegex: /^\/menu$/, + pathParams: [], + pathScore: 1, + servers: [], + requestParameters: [], + requestBodyContent: {}, + requestBodyRequired: false, + responseBodyContent: {}, + security: undefined, + securitySchemes: {}, + specSource: 'openapi.yaml', + }, + pathParams: {}, + }; +} + +function createContext( + requestUrl: string, + options?: { + headers?: Record; + protocolKnown?: boolean; + } +): RuleContext { + const parsedUrl = new URL(requestUrl); + const exchange: NormalizedExchange = { + index: 0, + source: 'test', + request: { + method: 'GET', + url: requestUrl, + path: parsedUrl.pathname, + query: parsedUrl.searchParams, + protocol: parsedUrl.protocol, + protocolKnown: options?.protocolKnown ?? true, + host: parsedUrl.host, + headers: options?.headers ?? {}, + }, + response: { + status: 200, + headers: {}, + }, + }; + + return { + exchange, + matchedOperation: createMatchedOperation(), + matchMode: 'strict-host', + hostCompatibleWithSpecServers: true, + validateSchema: () => ({ valid: true, errors: [] }), + }; +} + +describe('security-baseline insecure transport check', () => { + const rule = new SecurityRule(); + const authHeaders = { authorization: 'Bearer test-token' }; + + function transportFindings(context: RuleContext) { + return rule + .analyze(context) + .filter( + (finding) => + finding.message === 'Potential credential exposure over insecure HTTP transport' + ); + } + + it('flags authenticated requests over plain HTTP to a public host', () => { + const findings = transportFindings( + createContext('http://api.example.com/menu', { headers: authHeaders }) + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + ruleId: 'security-baseline', + severity: 'warning', + details: { hasAuthorizationHeader: true }, + }); + }); + + it('flags sensitive query parameters over plain HTTP to a public host', () => { + const findings = transportFindings(createContext('http://api.example.com/menu?api_key=12345')); + + expect(findings).toHaveLength(1); + expect(findings[0].details).toMatchObject({ + sensitiveQueryKeys: ['api_key'], + }); + }); + + it.each([ + 'http://localhost:9000/menu', + 'http://sub.localhost:9000/menu', + 'http://127.0.0.1:4040/menu', + 'http://127.1.2.3/menu', + 'http://[::1]:8080/menu', + ])('does not flag authenticated plain-HTTP requests to loopback host %s', (url) => { + expect(transportFindings(createContext(url, { headers: authHeaders }))).toHaveLength(0); + }); + + it('does not flag sensitive query parameters sent to a loopback host', () => { + expect(transportFindings(createContext('http://localhost:9000/menu?token=abc'))).toHaveLength( + 0 + ); + }); + + it('does not flag unauthenticated plain-HTTP requests', () => { + expect(transportFindings(createContext('http://api.example.com/menu'))).toHaveLength(0); + }); + + it('does not flag authenticated HTTPS requests', () => { + expect( + transportFindings(createContext('https://api.example.com/menu', { headers: authHeaders })) + ).toHaveLength(0); + }); + + it('does not flag when the capture did not record the scheme', () => { + expect( + transportFindings( + createContext('http://api.example.com/menu', { + headers: authHeaders, + protocolKnown: false, + }) + ) + ).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/commands/drift/README.md b/packages/cli/src/commands/drift/README.md index b86972976e..3550da9dee 100644 --- a/packages/cli/src/commands/drift/README.md +++ b/packages/cli/src/commands/drift/README.md @@ -61,3 +61,6 @@ redocly drift ./traffic.har --api ./openapi.yaml --format json -o ./drift-report - JSON-array traffic files (HAR/Kong/webserver-json) are read fully into memory. For very large captures, prefer the NDJSON format. - Builtin `owasp-api-top10` is opt-in via `--rules owasp-api-top10`. +- Builtin `security-baseline` does not report insecure-transport findings for loopback hosts + (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`), so sandboxed captures — e.g. recorded + with `redocly proxy` against a local target during e2e runs — stay warning-free. diff --git a/packages/cli/src/commands/drift/rules/builtins/security.ts b/packages/cli/src/commands/drift/rules/builtins/security.ts index 9c92a8355e..2835f114da 100644 --- a/packages/cli/src/commands/drift/rules/builtins/security.ts +++ b/packages/cli/src/commands/drift/rules/builtins/security.ts @@ -1,4 +1,4 @@ -import type { Finding, RuleContext, TrafficRule } from '../../types/index.js'; +import type { Finding, NormalizedRequest, RuleContext, TrafficRule } from '../../types/index.js'; interface SecuritySchemeEvaluation { schemeName: string; @@ -202,7 +202,9 @@ function evaluateScheme(context: RuleContext, schemeName: string): SecuritySchem satisfied, reason: satisfied ? 'Authorization uses bearer token as expected for OAuth2/OpenID Connect.' - : `Authorization scheme "${actualAuthScheme ?? 'unknown'}" does not satisfy OAuth2/OpenID Connect (expected bearer).`, + : `Authorization scheme "${ + actualAuthScheme ?? 'unknown' + }" does not satisfy OAuth2/OpenID Connect (expected bearer).`, }; } @@ -323,7 +325,9 @@ function createSecuritySummary(issues: SecurityIssue[]): string { return `Authentication check failed. ${optionSummaries[0]}`; } - return `None of the documented authentication options matched. Any one of these options would satisfy the OpenAPI security requirements: ${optionSummaries.join(' | ')}`; + return `None of the documented authentication options matched. Any one of these options would satisfy the OpenAPI security requirements: ${optionSummaries.join( + ' | ' + )}`; } function getSensitiveQueryKeys(context: RuleContext): string[] { @@ -338,12 +342,35 @@ function getSensitiveQueryKeys(context: RuleContext): string[] { }); } +// Mirrors the W3C Secure Contexts definition of potentially trustworthy origins: +// localhost, *.localhost, 127.0.0.0/8, and [::1] never leave the machine. +const LOOPBACK_HOSTNAME_PATTERN = /^(?:(?:.+\.)?localhost|127(?:\.\d{1,3}){3}|::1)$/i; + +function isLoopbackRequest(request: NormalizedRequest): boolean { + let hostname: string | undefined; + try { + hostname = new URL(request.url).hostname; + } catch { + hostname = request.host?.replace(/:\d+$/, ''); + } + + if (!hostname) { + return false; + } + + return LOOPBACK_HOSTNAME_PATTERN.test(hostname.replace(/^\[|\]$/g, '')); +} + function shouldFlagInsecureTransport(context: RuleContext): { flag: boolean; hasAuthHeader: boolean; sensitiveQueryKeys: string[]; } { - if (context.exchange.request.protocol !== 'http:' || !context.exchange.request.protocolKnown) { + if ( + context.exchange.request.protocol !== 'http:' || + !context.exchange.request.protocolKnown || + isLoopbackRequest(context.exchange.request) + ) { return { flag: false, hasAuthHeader: false, From 03759e8a8daaaac8f2fdcbe8e8903bcaa3e8bd7f Mon Sep 17 00:00:00 2001 From: Adam Sobaniec Date: Mon, 13 Jul 2026 12:37:30 +0200 Subject: [PATCH 2/6] chore: proper handling of 400 responses --- .../drift-skip-request-checks-on-4xx.md | 5 + .../commands/drift/schema-rule.test.ts | 77 ++++++++++++ packages/cli/src/commands/drift/README.md | 4 + .../commands/drift/rules/builtins/schema.ts | 117 ++++++++++-------- 4 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 .changeset/drift-skip-request-checks-on-4xx.md create mode 100644 packages/cli/src/__tests__/commands/drift/schema-rule.test.ts diff --git a/.changeset/drift-skip-request-checks-on-4xx.md b/.changeset/drift-skip-request-checks-on-4xx.md new file mode 100644 index 0000000000..98df499287 --- /dev/null +++ b/.changeset/drift-skip-request-checks-on-4xx.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': patch +--- + +Fixed the `drift` command's `schema-consistency` rule reporting false-positive request findings (missing required parameter, missing required body, request-body schema mismatch) for exchanges the server rejected with a `4xx` client error. A `4xx` response means the server never accepted the request, so validating it against the operation's success-path contract flagged the server's own correct rejection as drift. Response-side validation still runs, so a documented error response whose shape differs from reality is still reported. diff --git a/packages/cli/src/__tests__/commands/drift/schema-rule.test.ts b/packages/cli/src/__tests__/commands/drift/schema-rule.test.ts new file mode 100644 index 0000000000..3a8f0da6db --- /dev/null +++ b/packages/cli/src/__tests__/commands/drift/schema-rule.test.ts @@ -0,0 +1,77 @@ +import { SchemaConsistencyRule } from '../../../commands/drift/rules/builtins/schema.js'; +import type { + MatchedOperation, + NormalizedExchange, + RuleContext, +} from '../../../commands/drift/types/index.js'; + +function createMatchedOperation(): MatchedOperation { + return { + operation: { + operationId: 'listOrderItems', + method: 'get', + pathTemplate: '/order-items', + pathRegex: /^\/order-items$/, + pathParams: [], + pathScore: 1, + servers: [], + requestParameters: [{ name: 'filter', in: 'query', required: true }], + requestBodyContent: {}, + requestBodyRequired: false, + responseBodyContent: {}, + security: undefined, + securitySchemes: {}, + specSource: 'openapi.yaml', + }, + pathParams: {}, + }; +} + +function createContext(responseStatus: number): RuleContext { + const requestUrl = 'https://api.example.com/order-items'; + const parsedUrl = new URL(requestUrl); + const exchange: NormalizedExchange = { + index: 0, + source: 'test', + request: { + method: 'GET', + url: requestUrl, + path: parsedUrl.pathname, + query: parsedUrl.searchParams, + protocol: parsedUrl.protocol, + protocolKnown: true, + host: parsedUrl.host, + headers: {}, + }, + response: { + status: responseStatus, + headers: {}, + }, + }; + + return { + exchange, + matchedOperation: createMatchedOperation(), + matchMode: 'strict-host', + hostCompatibleWithSpecServers: true, + validateSchema: () => ({ valid: true, errors: [] }), + }; +} + +describe('schema-consistency required parameter check', () => { + const rule = new SchemaConsistencyRule(); + + function missingFilterFindings(context: RuleContext) { + return rule + .analyze(context) + .filter((finding) => finding.message === 'Missing required query parameter: "filter"'); + } + + it('flags a missing required query parameter when the server accepted the request', () => { + expect(missingFilterFindings(createContext(200))).toHaveLength(1); + }); + + it('does not flag a missing required query parameter when the server rejected the request with 4xx', () => { + expect(missingFilterFindings(createContext(400))).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/commands/drift/README.md b/packages/cli/src/commands/drift/README.md index 3550da9dee..cb0374fc24 100644 --- a/packages/cli/src/commands/drift/README.md +++ b/packages/cli/src/commands/drift/README.md @@ -64,3 +64,7 @@ redocly drift ./traffic.har --api ./openapi.yaml --format json -o ./drift-report - Builtin `security-baseline` does not report insecure-transport findings for loopback hosts (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`), so sandboxed captures — e.g. recorded with `redocly proxy` against a local target during e2e runs — stay warning-free. +- Builtin `schema-consistency` skips request-side checks (required parameters, required body, + request-body schema) when the response is a `4xx` client error. + The server rejected the request, so validating it against the operation's success-path + contract would report the server's own correct rejection as drift. diff --git a/packages/cli/src/commands/drift/rules/builtins/schema.ts b/packages/cli/src/commands/drift/rules/builtins/schema.ts index 14fe5624e7..a1ade7cc07 100644 --- a/packages/cli/src/commands/drift/rules/builtins/schema.ts +++ b/packages/cli/src/commands/drift/rules/builtins/schema.ts @@ -349,6 +349,11 @@ function createUndocumentedParameterFindings( return findings; } +function isRequestRejectedByServer(context: RuleContext): boolean { + const response = context.exchange.response; + return response !== undefined && response.status >= 400 && response.status < 500; +} + function pickResponseSchema( context: RuleContext, matchedOperation: MatchedOperation @@ -417,74 +422,80 @@ export class SchemaConsistencyRule implements TrafficRule { const findings: Finding[] = []; const cookies = parseCookies(context.exchange.request.headers.cookie); - findings.push(...createUndocumentedParameterFindings(context, matchedOperation)); + // A 4xx response means the server rejected the request, so it never held the + // request to the operation's success-path contract. Validating that request + // against required parameters or the request-body schema would report the + // server's own correct rejection as drift, so skip the request-side checks. + if (!isRequestRejectedByServer(context)) { + findings.push(...createUndocumentedParameterFindings(context, matchedOperation)); - for (const parameter of matchedOperation.operation.requestParameters) { - if (context.ignoreCookies && parameter.in === 'cookie') { - continue; - } - - const actualValue = getActualParameterValue(parameter, context, cookies); + for (const parameter of matchedOperation.operation.requestParameters) { + if (context.ignoreCookies && parameter.in === 'cookie') { + continue; + } - if (parameter.required && (actualValue === undefined || actualValue === null)) { - findings.push({ - ruleId: this.id, - severity: 'error', - category: 'documentation', - message: `Missing required ${parameter.in} parameter: "${parameter.name}"`, - exchangeIndex: context.exchange.index, - operationId: matchedOperation.operation.operationId, - specSource: matchedOperation.operation.specSource, - target: 'request', - }); - continue; - } + const actualValue = getActualParameterValue(parameter, context, cookies); - validateParameter(parameter, actualValue, context, findings); - } + if (parameter.required && (actualValue === undefined || actualValue === null)) { + findings.push({ + ruleId: this.id, + severity: 'error', + category: 'documentation', + message: `Missing required ${parameter.in} parameter: "${parameter.name}"`, + exchangeIndex: context.exchange.index, + operationId: matchedOperation.operation.operationId, + specSource: matchedOperation.operation.specSource, + target: 'request', + }); + continue; + } - const requestContentType = context.exchange.request.contentType; - const requestSchema = pickSchemaByMime( - matchedOperation.operation.requestBodyContent, - requestContentType - ); + validateParameter(parameter, actualValue, context, findings); + } - const hasRequestBody = hasBodyContent(context.exchange.request.bodyText); + const requestContentType = context.exchange.request.contentType; + const requestSchema = pickSchemaByMime( + matchedOperation.operation.requestBodyContent, + requestContentType + ); - if (matchedOperation.operation.requestBodyRequired && !hasRequestBody) { - findings.push({ - ruleId: this.id, - severity: 'error', - category: 'documentation', - message: 'Missing required request body', - exchangeIndex: context.exchange.index, - operationId: matchedOperation.operation.operationId, - specSource: matchedOperation.operation.specSource, - target: 'request', - }); - } + const hasRequestBody = hasBodyContent(context.exchange.request.bodyText); - if (requestSchema && hasRequestBody && isJsonMime(requestContentType)) { - if (context.exchange.request.bodyJson === undefined) { + if (matchedOperation.operation.requestBodyRequired && !hasRequestBody) { findings.push({ ruleId: this.id, severity: 'error', - category: 'schema', - message: 'Request body is not valid JSON for JSON content-type', + category: 'documentation', + message: 'Missing required request body', exchangeIndex: context.exchange.index, operationId: matchedOperation.operation.operationId, specSource: matchedOperation.operation.specSource, target: 'request', }); - } else { - findings.push( - ...validateSchemaResult( - requestSchema, - context.exchange.request.bodyJson, - context, - 'request' - ) - ); + } + + if (requestSchema && hasRequestBody && isJsonMime(requestContentType)) { + if (context.exchange.request.bodyJson === undefined) { + findings.push({ + ruleId: this.id, + severity: 'error', + category: 'schema', + message: 'Request body is not valid JSON for JSON content-type', + exchangeIndex: context.exchange.index, + operationId: matchedOperation.operation.operationId, + specSource: matchedOperation.operation.specSource, + target: 'request', + }); + } else { + findings.push( + ...validateSchemaResult( + requestSchema, + context.exchange.request.bodyJson, + context, + 'request' + ) + ); + } } } From 37557f5a433823ce143b4163bf8def4d33e73443 Mon Sep 17 00:00:00 2001 From: Adam Sobaniec Date: Mon, 13 Jul 2026 14:07:14 +0200 Subject: [PATCH 3/6] feat: allow to ignore specific headers --- .changeset/drift-ignore-headers.md | 5 ++ docs/@v2/commands/drift.md | 11 +++++ docs/@v2/commands/proxy.md | 1 + .../commands/drift/schema-rule.test.ts | 36 +++++++++++++- .../cli/src/commands/drift/engine/runner.ts | 1 + .../drift/engine/validation-session.ts | 8 ++++ packages/cli/src/commands/drift/index.ts | 3 ++ .../commands/drift/rules/builtins/schema.ts | 2 +- .../cli/src/commands/drift/types/index.ts | 5 ++ packages/cli/src/commands/drift/utils/http.ts | 47 +++++++++++++++++-- packages/cli/src/commands/proxy/index.ts | 2 + packages/cli/src/index.ts | 10 ++++ 12 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 .changeset/drift-ignore-headers.md diff --git a/.changeset/drift-ignore-headers.md b/.changeset/drift-ignore-headers.md new file mode 100644 index 0000000000..810a91ee33 --- /dev/null +++ b/.changeset/drift-ignore-headers.md @@ -0,0 +1,5 @@ +--- +'@redocly/cli': minor +--- + +Added an `--ignore-headers` option to the experimental `drift` and `proxy` commands. It takes a comma-separated list of header names to skip in undocumented-header checks, and a trailing `*` matches by prefix (for example `x-consumer-*`). Use it to silence headers a gateway or proxy adds that are not part of the API contract. diff --git a/docs/@v2/commands/drift.md b/docs/@v2/commands/drift.md index ea030f9953..36e0f3a9f4 100644 --- a/docs/@v2/commands/drift.md +++ b/docs/@v2/commands/drift.md @@ -55,6 +55,7 @@ redocly drift --api [--match-mode=