Skip to content

Commit 9c67728

Browse files
authored
fix(tripwire): canonicalize the request path before matching (#57) (#58)
A hidden-path tripwire matched the raw path, so a scan could dress a decoy path up and slip past while a webserver still resolved it to the file being hunted: //.env, /%2Eenv, /%252Eenv, /static/..%2f.git/config, /x/../.env. Missing the scan defeats the point of the tripwire. canonicalizeTripwirePath now reduces the path the way a server would before matching — drop query/fragment, ASCII percent-decode (bounded, so double encoding resolves), collapse duplicate slashes, resolve . and .. keeping one trailing slash — and the configured decoy paths are canonicalized the same way so both sides line up. Legitimate lookalikes (/environment, /env/config) are unaffected. Full suite green.
1 parent 1a6b070 commit 9c67728

2 files changed

Lines changed: 98 additions & 6 deletions

File tree

‎packages/webdecoy/src/rules/tripwire-rule.ts‎

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,77 @@ export const DEFAULT_TRIPWIRE_PATHS: readonly string[] = [
4141
'/.vscode/sftp.json',
4242
];
4343

44-
/** Strip query string and fragment for path matching. */
45-
function normalizePath(path: string): string {
46-
return path.split('?')[0].split('#')[0];
44+
/**
45+
* Canonicalize a request path before matching a hidden-path tripwire.
46+
*
47+
* A scanner probing for `/.env` or `/.git/config` can dress the path up —
48+
* `//.env`, `/x/..%2f.git/config`, `/%2Eenv` — and a webserver still resolves it
49+
* to the file it was hunting for. Matching the raw string would miss the scan,
50+
* which is the one thing a tripwire exists to catch. So the query and fragment
51+
* are dropped and the path is reduced the way a server would: percent-decode
52+
* ASCII bytes, collapse duplicate slashes, and resolve `.` and `..` segments,
53+
* keeping a single trailing slash.
54+
*/
55+
export function canonicalizeTripwirePath(path: string): string {
56+
const noQuery = path.split('?')[0].split('#')[0];
57+
return cleanSlashesAndDots(decodePercentAscii(noQuery));
58+
}
59+
60+
function decodePercentAscii(s: string): string {
61+
// Bounded, so a double-encoded separator (`%252F` -> `%2F` -> `/`) resolves
62+
// without unbounded work. ASCII bytes only: high bytes stay encoded rather
63+
// than risk throwing (decodeURIComponent) on invalid UTF-8.
64+
for (let i = 0; i < 4; i++) {
65+
const next = decodePercentAsciiOnce(s);
66+
if (next === s) break;
67+
s = next;
68+
}
69+
return s;
70+
}
71+
72+
function decodePercentAsciiOnce(s: string): string {
73+
if (!s.includes('%')) return s;
74+
let out = '';
75+
for (let i = 0; i < s.length; i++) {
76+
if (s[i] === '%' && i + 2 < s.length) {
77+
const hi = unhexNibble(s.charCodeAt(i + 1));
78+
const lo = unhexNibble(s.charCodeAt(i + 2));
79+
if (hi >= 0 && lo >= 0) {
80+
const v = (hi << 4) | lo;
81+
if (v < 0x80) {
82+
out += String.fromCharCode(v);
83+
i += 2;
84+
continue;
85+
}
86+
}
87+
}
88+
out += s[i];
89+
}
90+
return out;
91+
}
92+
93+
function unhexNibble(c: number): number {
94+
if (c >= 48 && c <= 57) return c - 48; // 0-9
95+
if (c >= 97 && c <= 102) return c - 97 + 10; // a-f
96+
if (c >= 65 && c <= 70) return c - 65 + 10; // A-F
97+
return -1;
98+
}
99+
100+
function cleanSlashesAndDots(s: string): string {
101+
if (s === '') return s;
102+
const hadTrailing = s.length > 1 && s[s.length - 1] === '/';
103+
const stack: string[] = [];
104+
for (const p of s.split('/')) {
105+
if (p === '' || p === '.') continue;
106+
if (p === '..') {
107+
if (stack.length > 0) stack.pop();
108+
continue;
109+
}
110+
stack.push(p);
111+
}
112+
let res = '/' + stack.join('/');
113+
if (hadTrailing && res !== '/') res += '/';
114+
return res;
47115
}
48116

49117
export class TripwireRule implements Rule {
@@ -57,15 +125,17 @@ export class TripwireRule implements Rule {
57125
constructor(config: TripwireConfig = {}) {
58126
const paths = [...(config.paths ?? [])];
59127
if (config.includeDefaults ?? true) paths.push(...DEFAULT_TRIPWIRE_PATHS);
60-
this.exact = new Set(paths);
61-
this.prefixes = config.prefixes ?? [];
128+
// Canonicalize the configured paths too, so both sides of the match are
129+
// reduced the same way and a decoy written as `/foo/../bar` still lines up.
130+
this.exact = new Set(paths.map(canonicalizeTripwirePath));
131+
this.prefixes = (config.prefixes ?? []).map(canonicalizeTripwirePath);
62132
this.patterns = config.patterns ?? [];
63133
this.action = config.action ?? 'DENY';
64134
this.dryRun = config.dryRun ?? false;
65135
}
66136

67137
evaluate(context: RuleContext): RuleResult {
68-
const path = normalizePath(context.path);
138+
const path = canonicalizeTripwirePath(context.path);
69139
const hit =
70140
this.exact.has(path) ||
71141
this.prefixes.some((prefix) => path.startsWith(prefix)) ||

‎packages/webdecoy/src/rules/tripwire.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,28 @@ describe('TripwireRule', () => {
3636
expect(rule.evaluate(ctx('/.git/config#x')).action).toBe('DENY');
3737
});
3838

39+
it('catches a scan that obfuscates the decoy path (#57)', () => {
40+
const rule = new TripwireRule();
41+
// A server resolves each of these to a decoy path; matching the raw string
42+
// would miss the scan.
43+
for (const p of [
44+
'//.env', // duplicate leading slash
45+
'/%2Eenv', // percent-encoded dot
46+
'/%252Eenv', // double-encoded dot
47+
'/static/..%2f.git/config', // traversal with an encoded slash
48+
'/x/../.env', // plain traversal
49+
]) {
50+
expect(rule.evaluate(ctx(p)).action).toBe('DENY');
51+
}
52+
});
53+
54+
it('does not trip on a legitimate path that merely resembles a decoy (#57)', () => {
55+
const rule = new TripwireRule();
56+
for (const p of ['/environment', '/env/config', '/assets/env.js']) {
57+
expect(rule.evaluate(ctx(p)).action).toBe('ALLOW');
58+
}
59+
});
60+
3961
it('respects includeDefaults: false', () => {
4062
const rule = new TripwireRule({ paths: ['/trap'], includeDefaults: false });
4163
expect(rule.evaluate(ctx('/.env')).action).toBe('ALLOW');

0 commit comments

Comments
 (0)