Skip to content

Commit 0256217

Browse files
committed
fix stack matching for setting constant scriptlets. #500
AG-42083 Squashed commit of the following: commit 27a82d4 Author: slvvko <v.leleka@adguard.com> Date: Mon Feb 9 20:46:29 2026 -0500 fix destructuring of chainInfo commit 1c332a4 Author: slvvko <v.leleka@adguard.com> Date: Fri Feb 6 12:42:09 2026 -0500 improve tests commit 5a92c24 Author: slvvko <v.leleka@adguard.com> Date: Fri Feb 6 12:25:43 2026 -0500 fix test property name collision commit d61d3fa Author: slvvko <v.leleka@adguard.com> Date: Fri Feb 6 10:55:42 2026 -0500 fix stack matching for setting constant scriptlets
1 parent 2efe85d commit 0256217

7 files changed

Lines changed: 350 additions & 185 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
3636
setting `xhr.shouldBePrevented = false` could disable the scriptlet [#386].
3737
- Parsing of regexp patterns containing pipe `|` character in `signatureStr` arg
3838
of `trusted-suppress-native-method` scriptlet [#473].
39+
- Stack matching in `set-constant` and `trusted-set-constant` scriptlets —
40+
now checked at property access time instead of scriptlet initialization [#500].
3941

4042
[Unreleased]: https://github.com/AdguardTeam/Scriptlets/compare/v2.2.15...HEAD
4143
[#329]: https://github.com/AdguardTeam/Scriptlets/issues/329
@@ -44,6 +46,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
4446
[#461]: https://github.com/AdguardTeam/Scriptlets/issues/461
4547
[#473]: https://github.com/AdguardTeam/Scriptlets/issues/473
4648
[#486]: https://github.com/AdguardTeam/Scriptlets/issues/486
49+
[#500]: https://github.com/AdguardTeam/Scriptlets/issues/500
4750
[#507]: https://github.com/AdguardTeam/Scriptlets/issues/507
4851
[#528]: https://github.com/AdguardTeam/Scriptlets/issues/528
4952

src/helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,4 @@ export * from './node-text-utils';
4040
export * from './trusted-types-utils';
4141
export * from './value-matchers';
4242
export * from './click-utils';
43+
export * from './set-constant-utils';

src/helpers/set-constant-utils.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { hit } from './hit';
2+
import { getPropertyInChain } from './get-property-in-chain';
3+
import { isEmptyObject } from './object-utils';
4+
import { getDescriptorAddon } from './get-descriptor-addon';
5+
import { matchStackTrace } from './match-stack';
6+
import { type Source } from '../scriptlets';
7+
import { type ChainBase } from '../../types/types';
8+
9+
/**
10+
* Property handler interface used by trapProp.
11+
*/
12+
interface PropHandler {
13+
/**
14+
* Actual value of the property as set by page scripts,
15+
* stored so it can be returned when the constant override does not apply.
16+
*/
17+
factValue: unknown;
18+
19+
/**
20+
* Initializes the handler with the property's current value.
21+
*
22+
* @param a Existing value of the property at trap time.
23+
*
24+
* @returns `true` if the trap should proceed, `false` to cancel
25+
* (e.g. when `mustCancel` determines the value should not be overridden).
26+
*/
27+
init(a: unknown): boolean;
28+
29+
/**
30+
* Getter invoked when the trapped property is read.
31+
* Depending on the handler, it may return the constant value or the factual value.
32+
*
33+
* @returns Value to expose to the caller.
34+
*/
35+
get(): unknown;
36+
37+
/**
38+
* Setter invoked when the trapped property is written to.
39+
* Updates `factValue` and may trigger further chain traversal or constant reassignment.
40+
*
41+
* @param a New value being assigned to the property.
42+
*/
43+
set(a: unknown): void;
44+
}
45+
46+
/**
47+
* Configuration for the setChainPropAccess factory.
48+
*/
49+
interface SetChainPropAccessConfig {
50+
/**
51+
* Scriptlet source object for logging and hit reporting.
52+
*/
53+
source: Source;
54+
55+
/**
56+
* Stack trace pattern to match against; if falsy, constant is returned unconditionally.
57+
*/
58+
stack: string | undefined;
59+
60+
/**
61+
* Returns true if the constant assignment should be canceled based on the incoming value type.
62+
*/
63+
mustCancel: (value: unknown) => boolean;
64+
65+
/**
66+
* Defines a property descriptor on the base object to intercept get/set access.
67+
*
68+
* @param base Object on which to define the property.
69+
* @param prop Property name to intercept.
70+
* @param configurable Whether the property descriptor should be configurable.
71+
* @param handler Handler object containing `get` and `set` methods.
72+
*
73+
* @returns `true` if the trap was successfully defined, `false` otherwise.
74+
*/
75+
trapProp: (base: ChainBase, prop: string, configurable: boolean, handler: PropHandler) => boolean;
76+
77+
/**
78+
* Returns the current constant value (may change if `mustCancel` triggers reassignment).
79+
*
80+
* @returns Current constant value.
81+
*/
82+
getConstantValue: () => unknown;
83+
84+
/**
85+
* Updates the constant value when the end property setter detects a cancellation.
86+
*
87+
* @param value New constant value.
88+
*/
89+
setConstantValue: (value: unknown) => void;
90+
}
91+
92+
/**
93+
* Creates setChainPropAccess function for set-constant and trusted-set-constant scriptlets.
94+
*
95+
* Traverses given chain to set constant value to its end prop.
96+
* Chains that yet include non-object values (e.g null) are valid and will be
97+
* traversed when appropriate chain member is set by an external script.
98+
*
99+
* @param config Configuration object with scriptlet-specific dependencies.
100+
*
101+
* @returns `setChainPropAccess` function.
102+
*/
103+
export const createSetChainPropAccessor = (config: SetChainPropAccessConfig) => {
104+
const {
105+
source,
106+
stack,
107+
mustCancel,
108+
trapProp,
109+
getConstantValue,
110+
setConstantValue,
111+
} = config;
112+
113+
/**
114+
* Traverses the given chain to set the constant value to its end property.
115+
*
116+
* @param owner Base object of the chain.
117+
* @param property Property name to intercept.
118+
*/
119+
const setChainPropAccess = (owner: ChainBase, property: string): void => {
120+
const chainInfo = getPropertyInChain(owner, property);
121+
const { base, prop, chain } = chainInfo;
122+
123+
// Handler method init is used to keep track of factual value
124+
// and apply mustCancel() check only on end prop
125+
const inChainPropHandler = {
126+
factValue: undefined as unknown,
127+
init(a: unknown) {
128+
this.factValue = a;
129+
return true;
130+
},
131+
get() {
132+
return this.factValue;
133+
},
134+
set(a: unknown) {
135+
// Prevent breakage due to loop assignments like win.obj = win.obj
136+
if (this.factValue === a) {
137+
return;
138+
}
139+
140+
this.factValue = a;
141+
if (a instanceof Object) {
142+
setChainPropAccess(a as ChainBase, chain as string);
143+
}
144+
},
145+
};
146+
const endPropHandler = {
147+
factValue: undefined as unknown,
148+
descriptorAddon: getDescriptorAddon(),
149+
init(a: unknown) {
150+
if (mustCancel(a)) {
151+
return false;
152+
}
153+
this.factValue = a;
154+
return true;
155+
},
156+
get() {
157+
if (!stack) {
158+
hit(source);
159+
return getConstantValue();
160+
}
161+
if (!this.descriptorAddon.isAbortingSuspended) {
162+
this.descriptorAddon.isAbortingSuspended = true;
163+
let stackMatches = false;
164+
try {
165+
stackMatches = matchStackTrace(stack, new Error().stack || '');
166+
} catch (e) {
167+
// Invalid regex or other error - return original value
168+
this.descriptorAddon.isAbortingSuspended = false;
169+
return this.factValue;
170+
}
171+
this.descriptorAddon.isAbortingSuspended = false;
172+
if (stackMatches) {
173+
hit(source);
174+
return getConstantValue();
175+
}
176+
}
177+
return this.factValue;
178+
},
179+
set(a: unknown) {
180+
if (mustCancel(a)) {
181+
setConstantValue(a);
182+
return;
183+
}
184+
this.factValue = a;
185+
},
186+
};
187+
188+
// End prop case
189+
if (!chain) {
190+
trapProp(base, prop, false, endPropHandler);
191+
return;
192+
}
193+
194+
// Null prop in chain
195+
if (base !== undefined && base[prop] === null) {
196+
trapProp(base, prop, true, inChainPropHandler);
197+
return;
198+
}
199+
200+
// Empty object prop in chain
201+
if ((base instanceof Object || typeof base === 'object') && isEmptyObject(base)) {
202+
trapProp(base, prop, true, inChainPropHandler);
203+
}
204+
205+
// Defined prop in chain
206+
const propValue = owner[prop];
207+
if (propValue instanceof Object || (typeof propValue === 'object' && propValue !== null)) {
208+
setChainPropAccess(propValue as ChainBase, chain);
209+
}
210+
211+
// Undefined prop in chain
212+
trapProp(base, prop, true, inChainPropHandler);
213+
};
214+
215+
return setChainPropAccess;
216+
};

0 commit comments

Comments
 (0)