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
82 changes: 76 additions & 6 deletions packages/webdecoy/src/rules/tripwire-rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)) ||
Expand Down
22 changes: 22 additions & 0 deletions packages/webdecoy/src/rules/tripwire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading