diff --git a/packages/webdecoy/src/rules/tripwire-rule.ts b/packages/webdecoy/src/rules/tripwire-rule.ts index 70266cb..2b31583 100644 --- a/packages/webdecoy/src/rules/tripwire-rule.ts +++ b/packages/webdecoy/src/rules/tripwire-rule.ts @@ -41,9 +41,77 @@ export const DEFAULT_TRIPWIRE_PATHS: readonly string[] = [ '/.vscode/sftp.json', ]; -/** Strip query string and fragment for path matching. */ -function normalizePath(path: string): string { - return path.split('?')[0].split('#')[0]; +/** + * Canonicalize a request path before matching a hidden-path tripwire. + * + * A scanner probing for `/.env` or `/.git/config` can dress the path up — + * `//.env`, `/x/..%2f.git/config`, `/%2Eenv` — and a webserver still resolves it + * to the file it was hunting for. Matching the raw string would miss the scan, + * which is the one thing a tripwire exists to catch. So the query and fragment + * are dropped and the path is reduced the way a server would: percent-decode + * ASCII bytes, collapse duplicate slashes, and resolve `.` and `..` segments, + * keeping a single trailing slash. + */ +export function canonicalizeTripwirePath(path: string): string { + const noQuery = path.split('?')[0].split('#')[0]; + return cleanSlashesAndDots(decodePercentAscii(noQuery)); +} + +function decodePercentAscii(s: string): string { + // Bounded, so a double-encoded separator (`%252F` -> `%2F` -> `/`) resolves + // without unbounded work. ASCII bytes only: high bytes stay encoded rather + // than risk throwing (decodeURIComponent) on invalid UTF-8. + for (let i = 0; i < 4; i++) { + const next = decodePercentAsciiOnce(s); + if (next === s) break; + s = next; + } + return s; +} + +function decodePercentAsciiOnce(s: string): string { + if (!s.includes('%')) return s; + let out = ''; + for (let i = 0; i < s.length; i++) { + if (s[i] === '%' && i + 2 < s.length) { + const hi = unhexNibble(s.charCodeAt(i + 1)); + const lo = unhexNibble(s.charCodeAt(i + 2)); + if (hi >= 0 && lo >= 0) { + const v = (hi << 4) | lo; + if (v < 0x80) { + out += String.fromCharCode(v); + i += 2; + continue; + } + } + } + out += s[i]; + } + return out; +} + +function unhexNibble(c: number): number { + if (c >= 48 && c <= 57) return c - 48; // 0-9 + if (c >= 97 && c <= 102) return c - 97 + 10; // a-f + if (c >= 65 && c <= 70) return c - 65 + 10; // A-F + return -1; +} + +function cleanSlashesAndDots(s: string): string { + if (s === '') return s; + const hadTrailing = s.length > 1 && s[s.length - 1] === '/'; + const stack: string[] = []; + for (const p of s.split('/')) { + if (p === '' || p === '.') continue; + if (p === '..') { + if (stack.length > 0) stack.pop(); + continue; + } + stack.push(p); + } + let res = '/' + stack.join('/'); + if (hadTrailing && res !== '/') res += '/'; + return res; } export class TripwireRule implements Rule { @@ -57,15 +125,17 @@ export class TripwireRule implements Rule { constructor(config: TripwireConfig = {}) { const paths = [...(config.paths ?? [])]; if (config.includeDefaults ?? true) paths.push(...DEFAULT_TRIPWIRE_PATHS); - this.exact = new Set(paths); - this.prefixes = config.prefixes ?? []; + // Canonicalize the configured paths too, so both sides of the match are + // reduced the same way and a decoy written as `/foo/../bar` still lines up. + this.exact = new Set(paths.map(canonicalizeTripwirePath)); + this.prefixes = (config.prefixes ?? []).map(canonicalizeTripwirePath); this.patterns = config.patterns ?? []; this.action = config.action ?? 'DENY'; this.dryRun = config.dryRun ?? false; } evaluate(context: RuleContext): RuleResult { - const path = normalizePath(context.path); + const path = canonicalizeTripwirePath(context.path); const hit = this.exact.has(path) || this.prefixes.some((prefix) => path.startsWith(prefix)) || diff --git a/packages/webdecoy/src/rules/tripwire.test.ts b/packages/webdecoy/src/rules/tripwire.test.ts index 62b326b..d3ae1b5 100644 --- a/packages/webdecoy/src/rules/tripwire.test.ts +++ b/packages/webdecoy/src/rules/tripwire.test.ts @@ -36,6 +36,28 @@ describe('TripwireRule', () => { expect(rule.evaluate(ctx('/.git/config#x')).action).toBe('DENY'); }); + it('catches a scan that obfuscates the decoy path (#57)', () => { + const rule = new TripwireRule(); + // A server resolves each of these to a decoy path; matching the raw string + // would miss the scan. + for (const p of [ + '//.env', // duplicate leading slash + '/%2Eenv', // percent-encoded dot + '/%252Eenv', // double-encoded dot + '/static/..%2f.git/config', // traversal with an encoded slash + '/x/../.env', // plain traversal + ]) { + expect(rule.evaluate(ctx(p)).action).toBe('DENY'); + } + }); + + it('does not trip on a legitimate path that merely resembles a decoy (#57)', () => { + const rule = new TripwireRule(); + for (const p of ['/environment', '/env/config', '/assets/env.js']) { + expect(rule.evaluate(ctx(p)).action).toBe('ALLOW'); + } + }); + it('respects includeDefaults: false', () => { const rule = new TripwireRule({ paths: ['/trap'], includeDefaults: false }); expect(rule.evaluate(ctx('/.env')).action).toBe('ALLOW');