Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions packages/nextjs/src/captcha.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,6 @@
import {
createCaptchaEndpoints,
resolveClientIp,
normalizeIp,
type CaptchaEndpointsOptions,
type TrustedProxies,
} from '@webdecoy/node';
Expand All@@ -28,13 +27,7 @@ import {
* back on.
*/
function getIP(headers: Headers, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({ headers, trustProxy: trustProxy ?? 1 });
if (fromChain) return fromChain;
return (
normalizeIp(headers.get('x-real-ip')) ??
normalizeIp(headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
return resolveClientIp({ headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

export interface NextCaptchaOptions extends CaptchaEndpointsOptions {
Expand Down
15 changes: 4 additions & 11 deletions packages/nextjs/src/middleware.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -99,17 +99,10 @@
* `X-Forwarded-For`, never an override of one.
*/
function resolveIP(req: NextRequest, trustProxy: TrustedProxies | undefined): string {
const fromChain = resolveClientIp({
headers: req.headers,
trustProxy: trustProxy ?? 1,
});
if (fromChain) return fromChain;

return (
normalizeIp(req.headers.get('x-real-ip')) ??
normalizeIp(req.headers.get('x-vercel-forwarded-for')?.split(',').pop()) ??
'127.0.0.1'
);
// One resolver. `x-real-ip` and the platform header used to be read here as a
// fallback; they now live inside resolveClientIp, so this adapter reads no
// forwarding header of its own and cannot drift from the others.
return resolveClientIp({ headers: req.headers, trustProxy: trustProxy ?? 1 }) ?? '127.0.0.1';
}

/**
Expand DownExpand Up@@ -327,7 +320,7 @@
* });
* ```
*/
export function withBotProtection<T extends (...args: any[]) => any>(

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type

Check warning on line 323 in packages/nextjs/src/middleware.ts

View workflow job for this annotation

GitHub Actions/ Build (22)

Unexpected any. Specify a different type
handler: T,
config: WebDecoyConfig & WithBotProtectionOptions
): T {
Expand Down
39 changes: 39 additions & 0 deletions packages/webdecoy/src/client-ip.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,3 +302,42 @@ describe('resolveClientIp', () => {
});
});
});

describe('the single-header fallback', () => {
it('uses X-Real-IP when a proxy is declared but sends no chain', () => {
// nginx's default: X-Real-IP and no X-Forwarded-For.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('takes the last value of the platform header, not the first', () => {
const ip = resolveClientIp({
headers: h({ 'x-vercel-forwarded-for': '1.2.3.4, 203.0.113.9' }),
trustProxy: 1,
});
expect(ip).toBe('203.0.113.9');
});

it('prefers a real forwarding chain over either', () => {
const ip = resolveClientIp({
headers: h({ 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '203.0.113.9' }),
peer: '10.0.0.1',
trustProxy: 1,
});
expect(ip).toBe('198.51.100.7');
});

it('ignores both when no proxy is declared', () => {
// Under trustProxy: false we read no forwarding header at all. X-Real-IP is
// exactly as forgeable as the rest of them.
const ip = resolveClientIp({
headers: h({ 'x-real-ip': '1.2.3.4' }),
peer: '198.51.100.7',
});
expect(ip).toBe('198.51.100.7');
});
});
19 changes: 16 additions & 3 deletions packages/webdecoy/src/client-ip.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -262,14 +262,27 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef

const chain = forwardedChain(headers);

// A proxy that sets only `X-Real-IP` (nginx's default) or the platform's own
// header, with no forwarding chain to walk. Consulted here rather than in each
// adapter so there is one place that decides what counts as the client — the
// adapters reading these themselves is how the leftmost-XFF bug came to live
// in three copies.
//
// Only when the caller has said a proxy exists. Under `false` we read no
// forwarding header at all, and these are as forgeable as the rest.
const singleHeaderFallback = (): string | undefined =>
normalizeIp(readHeader(headers, 'x-real-ip')) ??
normalizeIp(readHeader(headers, 'x-vercel-forwarded-for')?.split(',').pop()) ??
undefined;

if (typeof trustProxy === 'number') {
if (!Number.isInteger(trustProxy) || trustProxy < 0) return peer;
// The client is the Nth entry from the right. A chain shorter than the
// configured depth means the request did not arrive the way the operator
// described it, so we believe none of it.
const index = chain.length - trustProxy;
if (index < 0 || index >= chain.length) return peer;
return chain[index] ?? peer;
if (index < 0 || index >= chain.length) return singleHeaderFallback() ?? peer;
return chain[index] ?? singleHeaderFallback() ?? peer;
}

// CIDR list: walk right to left, past addresses that belong to us. `peer`
Expand All@@ -284,5 +297,5 @@ export function resolveClientIp(options: ResolveClientIpOptions): string | undef
}
// Every hop was trusted, which means the outermost one is as far as the chain
// goes — that address is the client.
return (full[0] ?? undefined) ?? peer;
return (full[0] ?? undefined) ?? singleHeaderFallback() ?? peer;
}
151 changes: 151 additions & 0 deletions packages/webdecoy/src/invariants.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

/**
* Invariants a reviewer cannot see.
*
* Every finding in the 0.12.0 / 0.13.0 batch was the same shape: two places
* answering one question, and the surface picking the more flattering answer.
* The spoofable client IP survived in two adapters after the WordPress plugin
* had already fixed the same class of bug, because each call site read perfectly
* reasonably on its own and nothing connected them.
*
* These tests connect them. They read source rather than behaviour on purpose:
* the defect is never "this function is wrong", it is "there are two of these
* and they disagree", which no unit test of either one can catch.
*/

const PACKAGES = join(__dirname, '..', '..');

/** Every shipped .ts file across the workspace, tests and builds excluded. */
function sourceFiles(): string[] {
const out: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo') continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
continue;
}
if (!entry.endsWith('.ts')) continue;
if (entry.endsWith('.test.ts') || entry.endsWith('.spec.ts')) continue;
if (entry.endsWith('.generated.ts')) continue;
out.push(full);
}
};
walk(PACKAGES);
return out;
}

/** Lines of `file` that contain `needle`, ignoring comments. */
function hits(file: string, needle: RegExp): string[] {
const found: string[] = [];
readFileSync(file, 'utf8')
.split('\n')
.forEach((line, i) => {
const trimmed = line.trim();
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
if (needle.test(line)) found.push(`${relative(PACKAGES, file)}:${i + 1}`);
});
return found;
}

describe('one answer to "who is the client"', () => {
it('resolves the client IP only in client-ip.ts', () => {
// Reading a forwarding header anywhere else is how the leftmost-XFF bug
// lived in Express and Next.js after the same bug had been fixed elsewhere:
// three adapters, three copies, and no one place to correct.
const offenders = sourceFiles()
.filter((f) => !f.endsWith(join('webdecoy', 'src', 'client-ip.ts')))
// Matches *reading* the header to derive an address —
// `headers['x-forwarded-for']` or `.get('x-forwarded-for')` — not merely
// naming it. The detection engine legitimately lists these among the
// headers it inspects for suspicious shapes, and that is not IP
// resolution.
.flatMap((f) =>
hits(
f,
/(?:\.get\(|\[)\s*['"](?:x-forwarded-for|x-real-ip|cf-connecting-ip)['"]/i,
),
);

if (offenders.length > 0) {
throw new Error(
'These read a forwarding header directly instead of calling resolveClientIp().\n' +
'The leftmost value of X-Forwarded-For is written by the client, so trusting\n' +
'it hands an attacker the rate-limit key and the address on every detection.\n' +
'Use resolveClientIp({ headers, peer, trustProxy }).\n\n ' +
offenders.join('\n '),
);
}
});
});

describe('one answer to "what did we decide"', () => {
it('builds decisions only through the Decision class', () => {
// protect() returning a plain object literal is how `allowed` and
// `conclusion` drift apart, and how a spread silently strips the narrowing
// helpers off the result.
const sdk = join(PACKAGES, 'webdecoy', 'src', 'sdk.ts');
const source = readFileSync(sdk, 'utf8');

if (/return\s*\{\s*\n?\s*allowed:/.test(source)) {
throw new Error(
'sdk.ts returns a bare object with an `allowed` key. Every decision must be\n' +
'a `new Decision({...})`, or the conclusion and the boolean can disagree\n' +
'and the narrowing helpers are lost on the way out.',
);
}
});

it('never spreads a decision, which would drop its methods', () => {
const sdk = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'sdk.ts'), 'utf8');

if (/\{\s*\.\.\.(result|decision)\s*,/.test(sdk)) {
throw new Error(
'Spreading a Decision produces a plain object: `isDenied()` and `deniedBy()`\n' +
'vanish and the adapter silently loses them. Use decision.withEdge(...) or\n' +
'another method that returns a Decision.',
);
}
});
});

describe('one answer to "is this rule running"', () => {
it('every rule that can be starved reports NOT_RUN rather than ALLOW', () => {
// A rule that cannot evaluate must say so. Reporting ALLOW makes "checked
// and fine" indistinguishable from "never checked", which is how a filter
// rule with no enrichment looked like a passing IP reputation check.
const starvable = ['filter-rule.ts', 'web-bot-auth-rule.ts', 'rate-limit-rule.ts'];

for (const name of starvable) {
const source = readFileSync(join(PACKAGES, 'webdecoy', 'src', 'rules', name), 'utf8');
if (!source.includes("state: 'NOT_RUN'")) {
throw new Error(
`${name} has no NOT_RUN path. A rule that silently allows when its input ` +
`is missing is indistinguishable from one that ran and passed.`,
);
}
}
});
});

describe('the edge build stays edge-compatible', () => {
it('no node: import reaches a package that ships to Workers', () => {
// check:edge catches this at build time, but only for the entry points it
// is pointed at. This catches it in review, with the file named.
const edgePackages = ['webdecoy', 'nextjs', 'hono'];
const offenders = sourceFiles()
.filter((f) => edgePackages.some((p) => f.includes(join(PACKAGES, p, 'src'))))
.flatMap((f) => hits(f, /from ['"]node:/));

if (offenders.length > 0) {
throw new Error(
'A `node:` import anywhere in these graphs breaks the bundle for Cloudflare\n' +
'Workers and Vercel Edge, where most of this SDK is meant to run.\n\n ' +
offenders.join('\n '),
);
}
});
});
Loading