Skip to content

Commit 7838d78

Browse files
committed
fix(robustness): harden field/validation helpers; fix update-field persistence
Adversarial pass (50+ probes) surfaced 15 defects, all fixed test-first: gf_update_field saved a change before reporting it blocked (dependency guard now runs before the write, like deleteField); gf_add_field crashed on null properties/position and accepted empty/null/non-string field_type; gf_create_form fields:[null] threw an opaque TypeError instead of a clean error; null-input crashes in validateFieldConfig/detectFieldVariant/generateCompoundInputs/stripEntryMeta/DependencyTracker; stripEmpty/sanitize could stack-overflow on circular input; buildToolList emitted an undefined entry when gkReloadDef was omitted; validateFieldFilter silently stringified object values. Offline gate green (test:node 377, test:unit 100%, field-validation, publint, lint:docs); adversarial flags 13->3 (remaining 3 are non-bugs); live-verified on dev.test. Claude-Session: https://claude.ai/code/session_01LJHfTpQknHFs1j5ayj7fpo
1 parent 30dbb6e commit 7838d78

17 files changed

Lines changed: 212 additions & 56 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ This release lets the field tools work with custom and third-party field types,
1212
### 🐛 Fixed
1313
- **`gf_add_field` now accepts custom and third-party field types.** Field types added by other plugins (Gravity Perks, Gravity Wiz, and similar add-ons) were previously rejected because they aren't in the built-in registry. They are now created normally, with a note when type-specific defaults or sub-inputs aren't available; pass `inputs`/`choices` explicitly for custom compound or choice fields.
1414
- **Unrecognized field types no longer leave an internal marker in saved forms** when creating or updating a form.
15+
- **`gf_update_field` no longer saves a change it then reports as blocked.** When a field had dependent conditional logic and `force` wasn't set, the update was written before the "use force to proceed" response, so the check protected nothing. The dependency check now runs before the write (matching `gf_delete_field`).
16+
- **Hardened tool-input handling.** Malformed or hostile input to the field, validation, and dependency helpers (empty/`null`/non-string field types, `null` properties or position, a `null` entry in a form's `fields`, non-scalar filter values, self-referential objects) now returns a clean error or is handled safely instead of failing opaquely.
1517

1618
## [2.4.0] - 2026-06-19
1719

src/config/field-validation.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export class FieldAwareValidator {
4343
} else {
4444
errors.push({
4545
index: i,
46-
fieldId: field.id,
47-
fieldType: field.type,
46+
fieldId: field?.id,
47+
fieldType: field?.type,
4848
error: validation.error
4949
});
5050
}

src/config/validation.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ export class BaseValidator {
104104
throw new Error('Field filter must have a value');
105105
}
106106

107+
// Reject non-array objects: String({}) becomes "[object Object]", a silent
108+
// garbage filter value. Arrays stay valid for the multi-value operators
109+
// handled below; a plain object never is.
110+
if (typeof value === 'object' && !Array.isArray(value)) {
111+
throw new Error('Field filter value must be a string, number, boolean, or array');
112+
}
113+
107114
// GF normalizes operators case-insensitively (IN/in/In all match), so accept
108115
// any case rather than rejecting valid GF operators.
109116
const validOperators = getEnumValues('fieldOperators');

src/field-definitions/field-registry.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,9 @@ export function getStorageFormat(fieldType) {
902902
* Helper function to detect field variant
903903
*/
904904
export function detectFieldVariant(field) {
905+
if (!field || typeof field !== 'object') {
906+
return 'default';
907+
}
905908
const definition = fieldRegistry[field.type];
906909
if (!definition || !definition.variants) {
907910
return 'default';
@@ -927,6 +930,9 @@ export function detectFieldVariant(field) {
927930
* Validate field configuration
928931
*/
929932
export function validateFieldConfig(field) {
933+
if (!field || typeof field !== 'object') {
934+
return { isValid: false, error: 'Field must be an object' };
935+
}
930936
const definition = fieldRegistry[field.type];
931937

932938
// Unknown / third-party types are tolerated, not rejected (Gravity Forms
@@ -1059,6 +1065,9 @@ export function getCompoundFieldInputs(fieldType) {
10591065
* @returns {array|null} Array of input definitions or null if not a compound field.
10601066
*/
10611067
export function generateCompoundInputs(field) {
1068+
if (!field || typeof field !== 'object') {
1069+
return null;
1070+
}
10621071
const fieldDef = fieldRegistry[field.type];
10631072

10641073
if (!fieldDef || !fieldDef.isCompound) {

src/field-operations/field-dependencies.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export class DependencyTracker {
1818
dynamicPopulation: []
1919
};
2020

21+
if (!form || typeof form !== 'object') {
22+
return dependencies;
23+
}
24+
2125
// 1. Check conditional logic dependencies in all fields
2226
this.scanConditionalLogic(form, fieldId, dependencies);
2327

@@ -260,10 +264,13 @@ export class DependencyTracker {
260264
* Check if dependencies would break form functionality
261265
*/
262266
hasBreakingDependencies(dependencies) {
267+
if (!dependencies || typeof dependencies !== 'object') {
268+
return false;
269+
}
263270
return (
264-
dependencies.conditionalLogic.length > 0 ||
265-
dependencies.calculations.length > 0 ||
266-
dependencies.mergeTags.length > 0
271+
(dependencies.conditionalLogic?.length > 0) ||
272+
(dependencies.calculations?.length > 0) ||
273+
(dependencies.mergeTags?.length > 0)
267274
);
268275
}
269276

src/field-operations/field-manager.js

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export class FieldManager {
2727
* @returns {object} Field creation result with warnings
2828
*/
2929
async addField(formId, fieldType, properties = {}, position = {}) {
30+
if (typeof fieldType !== 'string' || fieldType.trim() === '') {
31+
throw new Error('field_type is required and must be a non-empty string');
32+
}
33+
// Parameter defaults only cover `undefined`; coerce an explicit null so
34+
// adversarial input can't crash createField or the position engine.
35+
properties = properties || {};
36+
position = position || {};
37+
3038
// The registry is an ENHANCEMENT source, not a gate. Known types get
3139
// type-specific defaults and sub-inputs. Unknown types (third-party add-ons,
3240
// GravityKit, custom fields) are still created (Gravity Forms accepts them on
@@ -95,31 +103,46 @@ export class FieldManager {
95103
/**
96104
* Update existing field with dependency checking
97105
*/
98-
async updateField(formId, fieldId, updates = {}) {
106+
async updateField(formId, fieldId, updates = {}, options = {}) {
107+
const { force = false } = options;
108+
99109
// Fetch form
100110
const { form } = await this.api.getForm({ id: formId });
101-
111+
102112
// Find field
103113
const fieldIndex = form.fields?.findIndex(f => f.id == fieldId);
104-
if (fieldIndex === -1) {
114+
if (fieldIndex === undefined || fieldIndex === -1) {
105115
throw new Error(`Field ${fieldId} not found in form ${formId}`);
106116
}
107-
108-
// Check dependencies
117+
118+
// Gate the write on dependencies BEFORE mutating, the way deleteField does.
119+
// Saving first and reporting failure afterward persisted a "blocked" update
120+
// and made the success:false response a lie.
109121
const dependencies = this.dependencyTracker?.scanFormDependencies(form, fieldId) || {};
110-
122+
const hasBreakingDeps = dependencies.conditionalLogic?.length > 0;
123+
124+
if (hasBreakingDeps && !force) {
125+
return {
126+
success: false,
127+
error: 'Field has dependencies that may be affected',
128+
field_id: fieldId,
129+
dependencies,
130+
suggestion: 'Use force=true to update anyway'
131+
};
132+
}
133+
111134
// Apply updates
112135
const originalField = { ...form.fields[fieldIndex] };
113136
form.fields[fieldIndex] = {
114137
...originalField,
115-
...updates,
138+
...(updates || {}),
116139
id: originalField.id // Preserve ID
117140
};
118141
this.normalizeLayoutProperties(form.fields[fieldIndex], formId);
119142

120-
// Replace form via direct PUT (no re-fetch we already have the full state)
143+
// Replace form via direct PUT (no re-fetch; we already have the full state)
121144
const result = await this.api.replaceForm(formId, form);
122-
145+
123146
return {
124147
success: true,
125148
field: result.form.fields[fieldIndex],
@@ -128,8 +151,7 @@ export class FieldManager {
128151
after: result.form.fields[fieldIndex]
129152
},
130153
warnings: {
131-
dependencies: dependencies.conditionalLogic?.length > 0 ?
132-
['Field has conditional logic dependencies'] : [],
154+
dependencies: hasBreakingDeps ? ['Field has conditional logic dependencies'] : [],
133155
validationIssues: this.validator.getWarnings(result.form.fields[fieldIndex])
134156
}
135157
};

src/field-operations/index.js

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -72,26 +72,9 @@ export const fieldOperationHandlers = {
7272
async gf_update_field(params, { fieldManager }) {
7373
const { form_id, field_id, properties, force = false } = params;
7474

75-
const result = await fieldManager.updateField(
76-
form_id,
77-
field_id,
78-
properties
79-
);
80-
81-
// Check for breaking changes if not forced
82-
if (!force && result.warnings?.dependencies?.length > 0) {
83-
return {
84-
success: false,
85-
error: 'Field has dependencies that may be affected',
86-
...result,
87-
suggestion: 'Use force=true to update anyway'
88-
};
89-
}
90-
91-
return {
92-
success: true,
93-
...result
94-
};
75+
// updateField gates the write on dependencies and returns the final
76+
// success/failure shape; it no longer saves before reporting a block.
77+
return fieldManager.updateField(form_id, field_id, properties, { force });
9578
},
9679

9780
/**

src/server-runtime.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function buildToolList({ gfReady, gfToolDefs = [], fieldOpTools = [], abi
2929
...(gfReady ? [...gfToolDefs, ...fieldOpTools] : []),
3030
...(abilityDefs ?? []),
3131
gkReloadDef,
32-
];
32+
].filter(Boolean);
3333
}
3434

3535
/**

src/utils/compact.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@
1212
* @param {*} obj - Value to compact
1313
* @returns {*} Compacted value
1414
*/
15-
export function stripEmpty(obj) {
15+
export function stripEmpty(obj, seen = new WeakSet()) {
1616
if (Array.isArray(obj)) {
17-
return obj.map(stripEmpty);
17+
if (seen.has(obj)) return obj;
18+
seen.add(obj);
19+
return obj.map((v) => stripEmpty(v, seen));
1820
}
1921
if (obj !== null && typeof obj === 'object') {
22+
if (seen.has(obj)) return obj;
23+
seen.add(obj);
2024
const result = {};
2125
for (const [key, value] of Object.entries(obj)) {
2226
if (value === null || value === '') continue;
23-
result[key] = stripEmpty(value);
27+
result[key] = stripEmpty(value, seen);
2428
}
2529
return result;
2630
}
@@ -53,6 +57,9 @@ function isFieldKey(key) {
5357
* @returns {object} Entry with only core + field keys
5458
*/
5559
export function stripEntryMeta(entry) {
60+
if (!entry || typeof entry !== 'object') {
61+
return {};
62+
}
5663
const result = {};
5764
for (const [key, value] of Object.entries(entry)) {
5865
if (CORE_ENTRY_KEYS.has(key) || isFieldKey(key)) {

src/utils/sanitize.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,15 @@ function mask(value) {
4343
/**
4444
* Sanitize an object for logging
4545
*/
46-
export function sanitize(obj) {
46+
export function sanitize(obj, seen = new WeakSet()) {
4747
if (!obj || typeof obj !== 'object') return obj;
4848

49+
// Cut cycles so logging a self-referential object can never stack-overflow.
50+
if (seen.has(obj)) return Array.isArray(obj) ? [] : {};
51+
seen.add(obj);
52+
4953
if (Array.isArray(obj)) {
50-
return obj.map(sanitize);
54+
return obj.map((v) => sanitize(v, seen));
5155
}
5256

5357
const result = {};
@@ -58,7 +62,7 @@ export function sanitize(obj) {
5862
if (isSensitive) {
5963
result[key] = mask(value);
6064
} else if (typeof value === 'object' && value !== null) {
61-
result[key] = sanitize(value);
65+
result[key] = sanitize(value, seen);
6266
} else {
6367
result[key] = value;
6468
}

0 commit comments

Comments
 (0)