@teleporthq/teleport-plugin-next-workflows 0.43.32 → 0.43.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/client-config-redaction.test.ts +192 -0
- package/__tests__/data-api-boolean-checkbox-coercion.test.ts +143 -0
- package/__tests__/data-api-safe-query.test.ts +3 -1
- package/__tests__/data-update-item-runtime.test.ts +116 -0
- package/__tests__/raw-query-param-binding.test.ts +216 -0
- package/__tests__/runtime-template-token-resolution.test.ts +38 -0
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +92 -6
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +31 -0
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-delete-item.js +15 -4
- package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-raw-query.js +7 -1
- package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
- package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-select.js +51 -3
- package/dist/cjs/nodes/data/data-select.js.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/cjs/nodes/data/data-update-item.js +58 -5
- package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
- package/dist/cjs/nodes/general/general-extract-form-data.d.ts.map +1 -1
- package/dist/cjs/nodes/general/general-extract-form-data.js +37 -1
- package/dist/cjs/nodes/general/general-extract-form-data.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts +26 -2
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +45 -1
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/sql-query-validator.d.ts +40 -0
- package/dist/cjs/sql-query-validator.d.ts.map +1 -1
- package/dist/cjs/sql-query-validator.js +49 -1
- package/dist/cjs/sql-query-validator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +22 -8
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +8 -2
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +92 -6
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +31 -0
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-delete-item.js +15 -4
- package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-raw-query.js +7 -1
- package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
- package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-select.js +51 -3
- package/dist/esm/nodes/data/data-select.js.map +1 -1
- package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
- package/dist/esm/nodes/data/data-update-item.js +58 -5
- package/dist/esm/nodes/data/data-update-item.js.map +1 -1
- package/dist/esm/nodes/general/general-extract-form-data.d.ts.map +1 -1
- package/dist/esm/nodes/general/general-extract-form-data.js +37 -1
- package/dist/esm/nodes/general/general-extract-form-data.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts +26 -2
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +42 -0
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/sql-query-validator.d.ts +40 -0
- package/dist/esm/sql-query-validator.d.ts.map +1 -1
- package/dist/esm/sql-query-validator.js +47 -0
- package/dist/esm/sql-query-validator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +23 -9
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +9 -3
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/data-api-route-generator.ts +92 -6
- package/src/executor-generator.ts +31 -0
- package/src/nodes/data/data-delete-item.ts +15 -4
- package/src/nodes/data/data-raw-query.ts +7 -1
- package/src/nodes/data/data-select.ts +48 -3
- package/src/nodes/data/data-update-item.ts +62 -5
- package/src/nodes/general/general-extract-form-data.ts +37 -1
- package/src/segment-splitter.ts +49 -0
- package/src/sql-query-validator.ts +64 -0
- package/src/workflow-component-plugin.ts +23 -3
- package/src/workflow-project-plugin.ts +18 -3
|
@@ -176,9 +176,26 @@ async function safeQuery(client, sql, params, mode) {
|
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
function
|
|
179
|
+
function isSkippableFilterValue(value) {
|
|
180
|
+
if (value === undefined || value === null) return true;
|
|
181
|
+
if (typeof value === 'string' && value.trim() === '') return true;
|
|
182
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAllSentinelFilterValue(value, filter) {
|
|
187
|
+
if (typeof value !== 'string') return false;
|
|
188
|
+
var normalized = value.trim().toLowerCase();
|
|
189
|
+
if (filter && typeof filter.treatAsAll === 'string' && filter.treatAsAll.trim().toLowerCase() === normalized) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return normalized === 'all' || normalized === 'any' || normalized === 'everything' || normalized === '__all__';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildWhereClause(filters, queryParams, startIndex, options) {
|
|
180
196
|
var conditions = [];
|
|
181
197
|
var paramIndex = startIndex;
|
|
198
|
+
var skipOptionalEmpty = !!(options && options.skipOptionalEmpty);
|
|
182
199
|
|
|
183
200
|
if (!filters || !Array.isArray(filters) || filters.length === 0) {
|
|
184
201
|
return { clause: '', paramIndex: paramIndex };
|
|
@@ -194,6 +211,7 @@ function buildWhereClause(filters, queryParams, startIndex) {
|
|
|
194
211
|
var operand = f.operand || f.operator || '=';
|
|
195
212
|
|
|
196
213
|
if (value === undefined) continue;
|
|
214
|
+
if (skipOptionalEmpty && (isSkippableFilterValue(value) || isAllSentinelFilterValue(value, f))) continue;
|
|
197
215
|
|
|
198
216
|
if (Array.isArray(value)) {
|
|
199
217
|
if (value.length === 0) continue;
|
|
@@ -211,6 +229,37 @@ function buildWhereClause(filters, queryParams, startIndex) {
|
|
|
211
229
|
conditions.push(field + ' IS NOT NULL');
|
|
212
230
|
}
|
|
213
231
|
} else {
|
|
232
|
+
var normalizedOperand = String(operand || '=').toLowerCase();
|
|
233
|
+
if (normalizedOperand === 'contains' || normalizedOperand === 'not_contains' || normalizedOperand === 'not-contains') {
|
|
234
|
+
if (isSkippableFilterValue(value)) {
|
|
235
|
+
conditions.push('FALSE');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
conditions.push(field + (normalizedOperand === 'contains' ? ' ILIKE ' : ' NOT ILIKE ') + '$' + paramIndex);
|
|
239
|
+
queryParams.push('%' + String(value) + '%');
|
|
240
|
+
paramIndex++;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (normalizedOperand === 'startswith' || normalizedOperand === 'starts_with' || normalizedOperand === 'starts-with') {
|
|
244
|
+
if (isSkippableFilterValue(value)) {
|
|
245
|
+
conditions.push('FALSE');
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
conditions.push(field + ' ILIKE $' + paramIndex);
|
|
249
|
+
queryParams.push(String(value) + '%');
|
|
250
|
+
paramIndex++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (normalizedOperand === 'endswith' || normalizedOperand === 'ends_with' || normalizedOperand === 'ends-with') {
|
|
254
|
+
if (isSkippableFilterValue(value)) {
|
|
255
|
+
conditions.push('FALSE');
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
conditions.push(field + ' ILIKE $' + paramIndex);
|
|
259
|
+
queryParams.push('%' + String(value));
|
|
260
|
+
paramIndex++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
214
263
|
var validOps = ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'ILIKE'];
|
|
215
264
|
var sqlOp = validOps.indexOf(operand) !== -1 ? operand : '=';
|
|
216
265
|
conditions.push(field + ' ' + sqlOp + ' $' + paramIndex);
|
|
@@ -253,7 +302,7 @@ async function handleSelect(client, body) {
|
|
|
253
302
|
var cols = selectedColumns.length > 0 ? selectedColumns.join(', ') : '*';
|
|
254
303
|
var sql = 'SELECT ' + cols + ' FROM ' + tableName;
|
|
255
304
|
|
|
256
|
-
var where = buildWhereClause(filters, queryParams, 1);
|
|
305
|
+
var where = buildWhereClause(filters, queryParams, 1, { skipOptionalEmpty: true });
|
|
257
306
|
sql += where.clause;
|
|
258
307
|
|
|
259
308
|
if (sorts.length > 0) {
|
|
@@ -278,7 +327,11 @@ async function handleSelect(client, body) {
|
|
|
278
327
|
// Validate the raw user-provided query for forbidden operations
|
|
279
328
|
assertQuerySafe(rawQueryUserPart);
|
|
280
329
|
sql = rawQueryUserPart;
|
|
281
|
-
|
|
330
|
+
// SECURITY: the raw override fully replaces the assembled SELECT, so bind the
|
|
331
|
+
// security net's $N params for it, never inline text. rawQueryUserPartParams
|
|
332
|
+
// carries the workflow values (already resolved by the executor) that back the
|
|
333
|
+
// $1, $2, … placeholders the net emitted into rawQueryUserPart.
|
|
334
|
+
queryParams = Array.isArray(body.rawQueryUserPartParams) ? body.rawQueryUserPartParams : [];
|
|
282
335
|
}
|
|
283
336
|
|
|
284
337
|
// Final validation of the assembled query
|
|
@@ -289,7 +342,7 @@ async function handleSelect(client, body) {
|
|
|
289
342
|
|
|
290
343
|
var countSql = 'SELECT COUNT(*) FROM ' + tableName;
|
|
291
344
|
var countParams = [];
|
|
292
|
-
var countWhere = buildWhereClause(filters, countParams, 1);
|
|
345
|
+
var countWhere = buildWhereClause(filters, countParams, 1, { skipOptionalEmpty: true });
|
|
293
346
|
countSql += countWhere.clause;
|
|
294
347
|
var countResult = await safeQuery(client, countSql, countParams, 'count');
|
|
295
348
|
var count = parseInt(countResult.rows[0].count, 10);
|
|
@@ -306,7 +359,7 @@ async function handleCount(client, body) {
|
|
|
306
359
|
|
|
307
360
|
var queryParams = [];
|
|
308
361
|
var sql = 'SELECT COUNT(*) FROM ' + tableName;
|
|
309
|
-
var where = buildWhereClause(filters, queryParams, 1);
|
|
362
|
+
var where = buildWhereClause(filters, queryParams, 1, { skipOptionalEmpty: true });
|
|
310
363
|
sql += where.clause;
|
|
311
364
|
|
|
312
365
|
var result = await safeQuery(client, sql, queryParams, 'count');
|
|
@@ -340,8 +393,31 @@ function splitCommaOrPgArrayString(val) {
|
|
|
340
393
|
return s.split(',').map(function(x) { return x.trim(); }).filter(Boolean);
|
|
341
394
|
}
|
|
342
395
|
|
|
396
|
+
// HTML checkbox convention: a checked box submits a truthy string value
|
|
397
|
+
// ('on' by default, or the input's own value attribute); an UNCHECKED box
|
|
398
|
+
// is omitted from FormData entirely, which general-extract-form-data (the
|
|
399
|
+
// client-side node that builds the workflow payload) represents as '' /
|
|
400
|
+
// undefined - never false. Without this branch that '' fell through to
|
|
401
|
+
// the generic empty-string-to-null guard below and was written as SQL
|
|
402
|
+
// NULL instead of false: a silent "not featured" -> NULL data corruption
|
|
403
|
+
// in the common case (breaks any WHERE is_featured = true/false filter,
|
|
404
|
+
// since NULL fails three-valued-logic equality), and a hard 23502
|
|
405
|
+
// not-null-violation whenever the boolean column itself is NOT NULL - the
|
|
406
|
+
// exact "update failed" error reported for an unchecked "feature this item"
|
|
407
|
+
// checkbox. "Not submitted" always means unchecked/false for an HTML
|
|
408
|
+
// checkbox, so this mapping is unambiguous and safe for every boolean
|
|
409
|
+
// column, not just one page's flag.
|
|
410
|
+
var BOOLEAN_TRUE_STRINGS = { 'on': 1, 'true': 1, '1': 1 };
|
|
411
|
+
|
|
343
412
|
function coerceValueForPgColumn(col, val, colTypes) {
|
|
344
413
|
var dt = colTypes[col];
|
|
414
|
+
if (dt === 'boolean') {
|
|
415
|
+
if (typeof val === 'boolean') return val;
|
|
416
|
+
if (val === undefined || val === null) return false;
|
|
417
|
+
if (typeof val === 'number') return val !== 0;
|
|
418
|
+
if (typeof val === 'string') return !!BOOLEAN_TRUE_STRINGS[val.trim().toLowerCase()];
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
345
421
|
if (dt !== 'ARRAY') return val;
|
|
346
422
|
if (val == null) return val;
|
|
347
423
|
if (Array.isArray(val)) return val;
|
|
@@ -670,6 +746,16 @@ async function handleRawQuery(client, body, req) {
|
|
|
670
746
|
return { rows: [], error: 'No query provided' };
|
|
671
747
|
}
|
|
672
748
|
|
|
749
|
+
// SECURITY: bind the workflow's context/state values as POSITIONAL parameters
|
|
750
|
+
// ($1, $2, …) instead of interpolating them into the SQL text. The generation
|
|
751
|
+
// net rewrites every {{state.X}}/{{Current User.id}}/… value interpolation into
|
|
752
|
+
// a $N placeholder and moves the {{…}} token into this sibling params array; the
|
|
753
|
+
// executor resolves those tokens to concrete values before POSTing, so here they
|
|
754
|
+
// arrive as plain bound values. A bound value can never terminate a string literal
|
|
755
|
+
// or alter query structure — SQL injection is impossible for the value class.
|
|
756
|
+
// Legacy raw queries carry no params array and run unparameterized (backward-compat).
|
|
757
|
+
var params = Array.isArray(body.params) ? body.params : [];
|
|
758
|
+
|
|
673
759
|
// Validate the raw query for forbidden operations before execution
|
|
674
760
|
assertQuerySafe(query);
|
|
675
761
|
|
|
@@ -677,7 +763,7 @@ async function handleRawQuery(client, body, req) {
|
|
|
677
763
|
await ensurePgVector(client);
|
|
678
764
|
}
|
|
679
765
|
|
|
680
|
-
var result = await safeQuery(client, query,
|
|
766
|
+
var result = await safeQuery(client, query, params, 'raw-query');
|
|
681
767
|
var rows = Array.isArray(result.rows) ? result.rows : [];
|
|
682
768
|
|
|
683
769
|
// Auto-fire the low-stock alert when the workflow runs the post-
|
|
@@ -294,6 +294,37 @@ function finalizeResolvedConfig(nodeType, config) {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
walk(config, false);
|
|
297
|
+
|
|
298
|
+
// BACKSTOP: a data-node FILTER value that STILL holds a literal {{…}} token
|
|
299
|
+
// after resolution is an unresolvable template — a JS expression, an embedded
|
|
300
|
+
// token, or an unknown root the runtime never substitutes (run d9a24741:
|
|
301
|
+
// "{{state.filterDateEnd || '2099-12-31'}} 23:59:59" and
|
|
302
|
+
// "{{Current Page Entity.guest_id}}"). Left alone it reaches SQL as literal
|
|
303
|
+
// text and breaks the query. For a READ (data-select / data-count) DROP the
|
|
304
|
+
// clause — the optional-filter idiom degrades to "no filter" (more rows, never
|
|
305
|
+
// a crash). For a WRITE the filter scopes the mutation, so an unscoped/partial
|
|
306
|
+
// filter is unsafe → surface a validation error (same posture as the
|
|
307
|
+
// route-param sentinel above).
|
|
308
|
+
if (isDataNode && config && Array.isArray(config.filters)) {
|
|
309
|
+
var isReadNode = nodeType === 'data-select' || nodeType === 'data-count';
|
|
310
|
+
var keptFilters = [];
|
|
311
|
+
for (var fi = 0; fi < config.filters.length; fi++) {
|
|
312
|
+
var filt = config.filters[fi];
|
|
313
|
+
var fval = filt && typeof filt === 'object'
|
|
314
|
+
? (filt.value !== undefined ? filt.value : filt.destination)
|
|
315
|
+
: undefined;
|
|
316
|
+
if (typeof fval === 'string' && /\\{\\{[\\s\\S]*?\\}\\}/.test(fval)) {
|
|
317
|
+
if (!isReadNode && !error) {
|
|
318
|
+
var fcol = (filt && (filt.column || filt.field || filt.source)) || 'filter';
|
|
319
|
+
error = 'Filter on "' + fcol + '" carries an unresolved template ({{…}}) and cannot safely scope this write';
|
|
320
|
+
}
|
|
321
|
+
continue; // drop the unresolvable clause
|
|
322
|
+
}
|
|
323
|
+
keptFilters.push(filt);
|
|
324
|
+
}
|
|
325
|
+
config.filters = keptFilters;
|
|
326
|
+
}
|
|
327
|
+
|
|
297
328
|
return error;
|
|
298
329
|
}
|
|
299
330
|
|
|
@@ -29,7 +29,13 @@ async function data_delete_item(config: any, context: any) {
|
|
|
29
29
|
'" needs a page route param not available here; nothing deleted (workflow continues)'
|
|
30
30
|
)
|
|
31
31
|
}
|
|
32
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
deletedId: null,
|
|
34
|
+
success: true,
|
|
35
|
+
deletedCount: 0,
|
|
36
|
+
affected: 0,
|
|
37
|
+
__skippedUnavailableFilter: true,
|
|
38
|
+
}
|
|
33
39
|
}
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -52,16 +58,21 @@ async function data_delete_item(config: any, context: any) {
|
|
|
52
58
|
deletedId: null,
|
|
53
59
|
success: false,
|
|
54
60
|
deletedCount: 0,
|
|
61
|
+
affected: 0,
|
|
55
62
|
error: data.error || 'Delete item failed',
|
|
56
63
|
}
|
|
57
64
|
}
|
|
65
|
+
// `affected` mirrors `deletedCount` so a success gate authored against either
|
|
66
|
+
// synonym resolves (the AI schema historically advertised `affected`).
|
|
67
|
+
const __deleted = data.deletedCount || 0
|
|
58
68
|
return {
|
|
59
69
|
deletedId: data.deletedId || null,
|
|
60
|
-
success:
|
|
61
|
-
deletedCount:
|
|
70
|
+
success: __deleted > 0,
|
|
71
|
+
deletedCount: __deleted,
|
|
72
|
+
affected: __deleted,
|
|
62
73
|
}
|
|
63
74
|
} catch (err: unknown) {
|
|
64
|
-
return { deletedId: null, success: false, error: (err as Error).message }
|
|
75
|
+
return { deletedId: null, success: false, affected: 0, error: (err as Error).message }
|
|
65
76
|
}
|
|
66
77
|
}
|
|
67
78
|
export const dataDeleteItem: NodeHandlerGenerator = {
|
|
@@ -3,13 +3,19 @@ import { NodeHandlerGenerator, handlerToString } from '../types'
|
|
|
3
3
|
async function data_raw_query(config: any, context: any) {
|
|
4
4
|
const dataSourceId = config.dataSourceId
|
|
5
5
|
const query = config.query
|
|
6
|
+
// SECURITY: values that back the query's $N placeholders ride here as bound
|
|
7
|
+
// params, never interpolated into the SQL text. The generation net stored each
|
|
8
|
+
// as a {{…}} token; resolveConfig has already substituted them to concrete
|
|
9
|
+
// values by the time this handler runs. Empty/absent when the query is a static
|
|
10
|
+
// literal (backward-compat: the API route then runs it unparameterized).
|
|
11
|
+
const params = Array.isArray(config.params) ? config.params : []
|
|
6
12
|
const baseUrl = (context && context.__baseUrl) || ''
|
|
7
13
|
|
|
8
14
|
try {
|
|
9
15
|
const response = await fetch(baseUrl + '/api/data/' + dataSourceId + '/raw-query', {
|
|
10
16
|
method: 'POST',
|
|
11
17
|
headers: { 'Content-Type': 'application/json' },
|
|
12
|
-
body: JSON.stringify({ query }),
|
|
18
|
+
body: JSON.stringify({ query, params }),
|
|
13
19
|
})
|
|
14
20
|
|
|
15
21
|
const data = await response.json()
|
|
@@ -9,8 +9,50 @@ async function data_select(config: any, context: any) {
|
|
|
9
9
|
const limit = config.limit
|
|
10
10
|
const skip = config.skip
|
|
11
11
|
const rawQueryUserPart = config.rawQueryUserPart
|
|
12
|
+
// SECURITY: bound values that back the raw override's $N placeholders. The
|
|
13
|
+
// generation net emitted them alongside rawQueryUserPart; resolveConfig has
|
|
14
|
+
// already substituted the {{…}} tokens to concrete values here.
|
|
15
|
+
const rawQueryUserPartParams = Array.isArray(config.rawQueryUserPartParams)
|
|
16
|
+
? config.rawQueryUserPartParams
|
|
17
|
+
: []
|
|
12
18
|
const baseUrl = (context && context.__baseUrl) || ''
|
|
13
19
|
|
|
20
|
+
function isEmptyFilterValue(value: any) {
|
|
21
|
+
if (value === undefined || value === null) return true
|
|
22
|
+
if (typeof value === 'string' && value.trim() === '') return true
|
|
23
|
+
if (Array.isArray(value) && value.length === 0) return true
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAllSentinel(value: any, filter: any) {
|
|
28
|
+
if (typeof value !== 'string') return false
|
|
29
|
+
const normalized = value.trim().toLowerCase()
|
|
30
|
+
const configured = filter && filter.treatAsAll
|
|
31
|
+
if (typeof configured === 'string' && configured.trim().toLowerCase() === normalized) {
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
normalized === 'all' ||
|
|
36
|
+
normalized === 'any' ||
|
|
37
|
+
normalized === 'everything' ||
|
|
38
|
+
normalized === '__all__'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shouldSkipOptionalFilter(filter: any) {
|
|
43
|
+
if (!filter) return true
|
|
44
|
+
const value = filter.destination !== undefined ? filter.destination : filter.value
|
|
45
|
+
if (isEmptyFilterValue(value)) return true
|
|
46
|
+
if (isAllSentinel(value, filter)) return true
|
|
47
|
+
if (Array.isArray(filter.skipIfValue) && filter.skipIfValue.indexOf(value) !== -1) return true
|
|
48
|
+
if (filter.skipIfEmpty === true && isEmptyFilterValue(value)) return true
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const effectiveFilters = filters.filter(function (filter: any) {
|
|
53
|
+
return !shouldSkipOptionalFilter(filter)
|
|
54
|
+
})
|
|
55
|
+
|
|
14
56
|
// A filter whose value is the unresolved route-param sentinel (see
|
|
15
57
|
// resolveTemplateTokenString in runtime-utils) — e.g. a select scoped to
|
|
16
58
|
// {{Current Page Entity.id}} that runs on a page with no such route param
|
|
@@ -19,8 +61,8 @@ async function data_select(config: any, context: any) {
|
|
|
19
61
|
// WHOLE workflow (a single unresolvable filter must never kill the note-create
|
|
20
62
|
// submit). Never fall through to an unscoped query. Observable via the warn +
|
|
21
63
|
// the __skippedUnavailableFilter marker.
|
|
22
|
-
for (let __fi = 0; __fi <
|
|
23
|
-
const __f: any =
|
|
64
|
+
for (let __fi = 0; __fi < effectiveFilters.length; __fi++) {
|
|
65
|
+
const __f: any = effectiveFilters[__fi]
|
|
24
66
|
if (
|
|
25
67
|
__f &&
|
|
26
68
|
(__f.value === '__TQ_UNRESOLVED_ROUTE_PARAM__' ||
|
|
@@ -39,7 +81,7 @@ async function data_select(config: any, context: any) {
|
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
try {
|
|
42
|
-
const payload: any = { tableName, filters, sorts, selectedColumns }
|
|
84
|
+
const payload: any = { tableName, filters: effectiveFilters, sorts, selectedColumns }
|
|
43
85
|
if (limit !== undefined && limit !== null) {
|
|
44
86
|
payload.limit = limit
|
|
45
87
|
}
|
|
@@ -48,6 +90,9 @@ async function data_select(config: any, context: any) {
|
|
|
48
90
|
}
|
|
49
91
|
if (rawQueryUserPart) {
|
|
50
92
|
payload.rawQueryUserPart = rawQueryUserPart
|
|
93
|
+
if (rawQueryUserPartParams.length > 0) {
|
|
94
|
+
payload.rawQueryUserPartParams = rawQueryUserPartParams
|
|
95
|
+
}
|
|
51
96
|
}
|
|
52
97
|
|
|
53
98
|
const response = await fetch(baseUrl + '/api/data/' + dataSourceId + '/select', {
|
|
@@ -30,7 +30,13 @@ async function data_update_item(config: any, context: any) {
|
|
|
30
30
|
'" needs a page route param not available here; no rows updated (workflow continues)'
|
|
31
31
|
)
|
|
32
32
|
}
|
|
33
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
id: null,
|
|
35
|
+
updatedCount: 0,
|
|
36
|
+
affected: 0,
|
|
37
|
+
success: true,
|
|
38
|
+
__skippedUnavailableFilter: true,
|
|
39
|
+
}
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
if (Array.isArray(columnMappings)) {
|
|
@@ -49,6 +55,43 @@ async function data_update_item(config: any, context: any) {
|
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// A columnMapping whose resolved value is `undefined` means its binding never
|
|
59
|
+
// produced a value: an unread page-entity column, or (most commonly) an empty
|
|
60
|
+
// form field that general-extract-form-data omits by default. Sending it would
|
|
61
|
+
// write NULL and break a NOT NULL column — run d9a24741 blanked
|
|
62
|
+
// guests.full_name from an un-prefilled guest_name input. OMIT such mappings so
|
|
63
|
+
// the column keeps its stored value. (An intentional null — e.g. a route-param
|
|
64
|
+
// sentinel degraded above — is preserved: only `undefined` is dropped.)
|
|
65
|
+
let effectiveColumnMappings: any = columnMappings
|
|
66
|
+
if (Array.isArray(columnMappings)) {
|
|
67
|
+
effectiveColumnMappings = columnMappings.filter(function (__m: any) {
|
|
68
|
+
return !(__m && typeof __m === 'object' && __m.value === undefined)
|
|
69
|
+
})
|
|
70
|
+
} else if (columnMappings && typeof columnMappings === 'object') {
|
|
71
|
+
effectiveColumnMappings = {}
|
|
72
|
+
const __ck = Object.keys(columnMappings)
|
|
73
|
+
for (let __ci = 0; __ci < __ck.length; __ci++) {
|
|
74
|
+
if ((columnMappings as any)[__ck[__ci]] !== undefined) {
|
|
75
|
+
;(effectiveColumnMappings as any)[__ck[__ci]] = (columnMappings as any)[__ck[__ci]]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const __hasWrites = Array.isArray(effectiveColumnMappings)
|
|
80
|
+
? effectiveColumnMappings.length > 0
|
|
81
|
+
: !!effectiveColumnMappings && Object.keys(effectiveColumnMappings).length > 0
|
|
82
|
+
if (!__hasWrites) {
|
|
83
|
+
// Nothing resolvable to write — never send an empty UPDATE (the DB would
|
|
84
|
+
// reject it / touch nothing). Report a clean zero-row no-op so a downstream
|
|
85
|
+
// updatedCount/affected gate takes its "no change" branch.
|
|
86
|
+
return {
|
|
87
|
+
id: null,
|
|
88
|
+
updatedCount: 0,
|
|
89
|
+
affected: 0,
|
|
90
|
+
success: true,
|
|
91
|
+
__skippedEmptyUpdate: true,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
52
95
|
// Same anonymous-user hint as data-create-item — a workflow that
|
|
53
96
|
// updates a row written by a guest must be able to re-stamp its
|
|
54
97
|
// ownership column with the same anon UUID rather than NULL.
|
|
@@ -72,7 +115,12 @@ async function data_update_item(config: any, context: any) {
|
|
|
72
115
|
}
|
|
73
116
|
|
|
74
117
|
try {
|
|
75
|
-
const reqBody: any = {
|
|
118
|
+
const reqBody: any = {
|
|
119
|
+
tableName,
|
|
120
|
+
filters,
|
|
121
|
+
columnMappings: effectiveColumnMappings,
|
|
122
|
+
returnUpdated: true,
|
|
123
|
+
}
|
|
76
124
|
if (__anonymousUserId) {
|
|
77
125
|
reqBody.__anonymousUserId = __anonymousUserId
|
|
78
126
|
}
|
|
@@ -90,11 +138,20 @@ async function data_update_item(config: any, context: any) {
|
|
|
90
138
|
const data = await response.json()
|
|
91
139
|
|
|
92
140
|
if (!response.ok) {
|
|
93
|
-
return { id: null, updatedCount: 0, error: data.error || 'Update item failed' }
|
|
141
|
+
return { id: null, updatedCount: 0, affected: 0, error: data.error || 'Update item failed' }
|
|
142
|
+
}
|
|
143
|
+
// `affected` mirrors `updatedCount` — a success gate authored against either
|
|
144
|
+
// synonym (the AI schema historically advertised `affected`) must resolve.
|
|
145
|
+
// Placed after the row spread so it can't be shadowed by a table column.
|
|
146
|
+
const __updated = data.updatedCount || 0
|
|
147
|
+
return {
|
|
148
|
+
id: data.id || null,
|
|
149
|
+
...(data.item || {}),
|
|
150
|
+
updatedCount: __updated,
|
|
151
|
+
affected: __updated,
|
|
94
152
|
}
|
|
95
|
-
return { id: data.id || null, ...(data.item || {}), updatedCount: data.updatedCount || 0 }
|
|
96
153
|
} catch (err: unknown) {
|
|
97
|
-
return { id: null, updatedCount: 0, error: (err as Error).message }
|
|
154
|
+
return { id: null, updatedCount: 0, affected: 0, error: (err as Error).message }
|
|
98
155
|
}
|
|
99
156
|
}
|
|
100
157
|
export const dataUpdateItem: NodeHandlerGenerator = {
|
|
@@ -97,11 +97,47 @@ async function general_extract_form_data(config: any, context: Record<string, un
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const fieldKeys = Object.keys(fields)
|
|
100
|
-
const result: Record<string, any> = { fields }
|
|
100
|
+
const result: Record<string, any> = { fields, formData: fields }
|
|
101
101
|
for (let fi = 0; fi < fieldKeys.length; fi++) {
|
|
102
102
|
result[fieldKeys[fi]] = fields[fieldKeys[fi]]
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Workflows generated/imported from HTML often refer to a form control by
|
|
106
|
+
// its DOM id (`class-session-select`) while FormData exposes submitted
|
|
107
|
+
// values by name (`class_session_id`). Preserve the canonical name keys
|
|
108
|
+
// above, and add non-overwriting aliases for id/data-form-field-id so both
|
|
109
|
+
// conventions resolve to the same submitted value.
|
|
110
|
+
try {
|
|
111
|
+
const addAlias = (alias: string | null | undefined, name: string | undefined) => {
|
|
112
|
+
if (!alias || !name) return
|
|
113
|
+
if (fields[name] === undefined) return
|
|
114
|
+
if (result[alias] === undefined) {
|
|
115
|
+
result[alias] = fields[name]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const controls = (formEl as HTMLFormElement).querySelectorAll(
|
|
119
|
+
'input[name], select[name], textarea[name], button[name]'
|
|
120
|
+
)
|
|
121
|
+
for (let ci = 0; ci < controls.length; ci++) {
|
|
122
|
+
const control = controls[ci] as HTMLInputElement
|
|
123
|
+
addAlias(control.id, control.name)
|
|
124
|
+
addAlias(control.getAttribute('data-form-field-id'), control.name)
|
|
125
|
+
}
|
|
126
|
+
const outsideControls = document.querySelectorAll(
|
|
127
|
+
'input[name], select[name], textarea[name], button[name]'
|
|
128
|
+
)
|
|
129
|
+
for (let oi = 0; oi < outsideControls.length; oi++) {
|
|
130
|
+
const control = outsideControls[oi] as HTMLInputElement
|
|
131
|
+
if (formEl.contains(control)) continue
|
|
132
|
+
const ownerForm = control.closest('form')
|
|
133
|
+
if (ownerForm && ownerForm !== formEl) continue
|
|
134
|
+
addAlias(control.id, control.name)
|
|
135
|
+
addAlias(control.getAttribute('data-form-field-id'), control.name)
|
|
136
|
+
}
|
|
137
|
+
} catch (_aliasErr) {
|
|
138
|
+
/* aliases are best-effort; canonical name-based fields remain intact */
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
// Diagnostic: surface any required inputs whose value is empty at extraction
|
|
106
142
|
// time so downstream custom-JS validators have something concrete to report
|
|
107
143
|
// instead of a generic "please fill in all required fields" message.
|
package/src/segment-splitter.ts
CHANGED
|
@@ -279,6 +279,55 @@ const enforceLoopBodyIntegrity = (
|
|
|
279
279
|
return { reorderedOrder: working, forcedEnvByNodeId }
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Resolve a node's intrinsic execution env using the exact same rules the
|
|
284
|
+
* segment walk applies, without any surrounding-segment context. Used by the
|
|
285
|
+
* client-config redactor to classify nodes that never land in a segment (e.g.
|
|
286
|
+
* error-handler branch nodes). Unknown/universal nodes resolve to 'client',
|
|
287
|
+
* the conservative default (their config is kept).
|
|
288
|
+
*/
|
|
289
|
+
export const resolveNodeExecutionEnv = (node: UIDLWorkflowNode): WorkflowExecutionEnv =>
|
|
290
|
+
resolveExecutionEnv(node, null)
|
|
291
|
+
|
|
292
|
+
// The ONLY field of a server node's config the CLIENT executor ever reads is
|
|
293
|
+
// the AI-streaming flag (findStreamingAINodes / callStreamingServerSegment in
|
|
294
|
+
// executor-generator). Everything else — the raw SQL query, filters, table
|
|
295
|
+
// name, bound params, column mappings, data source id, … — is consumed
|
|
296
|
+
// exclusively inside the server API route (see api-route-generator's
|
|
297
|
+
// SEGMENT_CONFIG, which keeps the full config server-side). None of it may
|
|
298
|
+
// reach the browser bundle.
|
|
299
|
+
export const CLIENT_SAFE_SERVER_CONFIG_KEYS: ReadonlyArray<string> = ['streaming']
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Strip a server node's config down to the client-safe whitelist before it is
|
|
303
|
+
* serialized into any bundle that ships to the browser (the page-level
|
|
304
|
+
* `__wfConfig_*` constants and the shared custom-nodes file).
|
|
305
|
+
*
|
|
306
|
+
* Safety: a server segment executes entirely inside its own API route, and the
|
|
307
|
+
* generated client node-handler file omits every server node type (see
|
|
308
|
+
* NextWorkflowProjectPlugin.generateNodeHandlerFile), so the browser has no
|
|
309
|
+
* handler to execute a server node with — it only needs a server node's
|
|
310
|
+
* { id, type, stepNumber, edges } for branch-skip bookkeeping plus
|
|
311
|
+
* `config.streaming` for streaming detection. Dropping the rest can therefore
|
|
312
|
+
* only remove data the client never acts on. Client and universal node configs
|
|
313
|
+
* are returned untouched.
|
|
314
|
+
*/
|
|
315
|
+
export const redactServerNodeConfig = (
|
|
316
|
+
config: Record<string, unknown>,
|
|
317
|
+
env: WorkflowExecutionEnv
|
|
318
|
+
): Record<string, unknown> => {
|
|
319
|
+
if (env !== 'server' || !config || typeof config !== 'object') {
|
|
320
|
+
return config
|
|
321
|
+
}
|
|
322
|
+
const safe: Record<string, unknown> = {}
|
|
323
|
+
for (const key of CLIENT_SAFE_SERVER_CONFIG_KEYS) {
|
|
324
|
+
if (config[key] !== undefined) {
|
|
325
|
+
safe[key] = config[key]
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return safe
|
|
329
|
+
}
|
|
330
|
+
|
|
282
331
|
export const getServerSegments = (segments: WorkflowSegment[]): WorkflowSegment[] => {
|
|
283
332
|
return segments.filter((s) => s.env === 'server')
|
|
284
333
|
}
|
|
@@ -693,6 +693,70 @@ export const validateSQLQuery = (query: string): SQLValidationResult => {
|
|
|
693
693
|
}
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// Raw-query parameterization safety
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Matches a single `{{ … }}` workflow-context template token anywhere in a
|
|
702
|
+
* string (non-greedy to the first `}}`). A token surviving in the SQL TEXT is
|
|
703
|
+
* the SQL-injection signal — the workflow executor substitutes it TEXTUALLY
|
|
704
|
+
* into the query. Parameterized binds use `$1` / `$2` placeholders instead.
|
|
705
|
+
*/
|
|
706
|
+
export const RAW_QUERY_TEMPLATE_TOKEN_RE = /\{\{[\s\S]+?\}\}/
|
|
707
|
+
|
|
708
|
+
export interface RawQuerySafetyWarning {
|
|
709
|
+
/** Stable id, aligned with the design-time security rule catalogue. */
|
|
710
|
+
ruleId: 'sql-unparameterized-context'
|
|
711
|
+
message: string
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export interface RawQuerySafetyResult {
|
|
715
|
+
/** True when no `{{ … }}` token remains in the SQL text (safe / parameterized). */
|
|
716
|
+
isSafe: boolean
|
|
717
|
+
/** Informational security warnings — empty for a correctly-parameterized query. */
|
|
718
|
+
warnings: RawQuerySafetyWarning[]
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Assess whether a raw-SQL string is safely PARAMETERIZED.
|
|
723
|
+
*
|
|
724
|
+
* SAFE (zero warnings): the SQL text carries only positional `$N` placeholders
|
|
725
|
+
* and every workflow-context/state value lives in the sibling `params`
|
|
726
|
+
* (`rawQueryUserPartParams`) array, bound at runtime via
|
|
727
|
+
* `client.query(sql, params)`. A bound value can never terminate a string
|
|
728
|
+
* literal or alter query structure, so injection is impossible for that class.
|
|
729
|
+
* A `{{ … }}` token appearing in the `params` array is the CORRECT, safe home
|
|
730
|
+
* for it and is intentionally NOT inspected here — only the SQL text is scanned.
|
|
731
|
+
*
|
|
732
|
+
* UNSAFE (one warning): a `{{ … }}` template token still appears in the SQL
|
|
733
|
+
* TEXT. The executor substitutes it textually, so an attacker-controlled value
|
|
734
|
+
* (e.g. a search term containing `'`) can break out and inject SQL. Post-net
|
|
735
|
+
* this should never happen — the generation-time net rewrites every value
|
|
736
|
+
* interpolation into `$N` and fails loud on anything it cannot bind — but the
|
|
737
|
+
* check stays as a runtime/publish-time backstop.
|
|
738
|
+
*
|
|
739
|
+
* @param query - The raw SQL text (e.g. `data-raw-query.query` /
|
|
740
|
+
* `data-select.rawQueryUserPart`).
|
|
741
|
+
*/
|
|
742
|
+
export const analyzeRawQueryParameterization = (query: unknown): RawQuerySafetyResult => {
|
|
743
|
+
if (typeof query !== 'string' || !RAW_QUERY_TEMPLATE_TOKEN_RE.test(query)) {
|
|
744
|
+
return { isSafe: true, warnings: [] }
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
isSafe: false,
|
|
748
|
+
warnings: [
|
|
749
|
+
{
|
|
750
|
+
ruleId: 'sql-unparameterized-context',
|
|
751
|
+
message:
|
|
752
|
+
'Raw SQL interpolates a workflow context value with `{{...}}` directly into the ' +
|
|
753
|
+
'query text. If that value is influenced by external input it could be used for ' +
|
|
754
|
+
'SQL injection. Bind the value via the query parameter list (e.g. `$1`) instead.',
|
|
755
|
+
},
|
|
756
|
+
],
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
696
760
|
// ---------------------------------------------------------------------------
|
|
697
761
|
// Context value risk assessment
|
|
698
762
|
// ---------------------------------------------------------------------------
|