Skip to content

Commit a78d8b1

Browse files
authored
chore: form state rework (#14771)
We now use `$state` instead of `$state.raw` for the internal representation of `value()/set(...)`. This has several advantages: - we can get rid of the version code which became quite a bit - you can put a `$state` object yourself into `set(...)` and then just push to it, making array operations very easy (closes #14662) - fine-grained without any additional footwork The reason I made this `$state.raw` originally is so that `fields.x.value()` would fire even if something within `x` would update, but honestly that's a bad reason looking back. You _can_ get that behavior by running JSON.stringify on the result, or you just access the properties you care about and it will all take care of it automatically. The one drawback is that prototype pollution prevention became a tiny but trickier, we now have to check for a set of keys. We should consider changing `$state` in Svelte itself to also proxify `Object.create(null)`
1 parent 4aca945 commit a78d8b1

File tree

5 files changed

+67
-107
lines changed

5 files changed

+67
-107
lines changed

.changeset/young-pans-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: rework internal representation of form value to be `$state`

packages/kit/src/runtime/app/server/remote/form.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
deep_set,
1212
normalize_issue,
1313
flatten_issues
14-
} from '../../../form-utils.svelte.js';
14+
} from '../../../form-utils.js';
1515
import { get_cache, run_remote_function } from './shared.js';
1616

1717
/**
@@ -215,7 +215,6 @@ export function form(validate_or_fn, maybe_fn) {
215215
return create_field_proxy(
216216
{},
217217
() => data?.input ?? {},
218-
() => {},
219218
(path, value) => {
220219
if (data?.submission) {
221220
// don't override a submission
@@ -318,7 +317,7 @@ function handle_issues(output, issues, is_remote_request, form_data) {
318317

319318
if (is_array) key = key.slice(0, -2);
320319

321-
output.input = set_nested_value(
320+
set_nested_value(
322321
/** @type {Record<string, any>} */ (output.input),
323322
key,
324323
is_array ? values : values[0]

packages/kit/src/runtime/client/remote-functions/form.svelte.js

Lines changed: 7 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,9 @@ import {
1717
deep_set,
1818
set_nested_value,
1919
throw_on_old_property_access,
20-
split_path,
2120
build_path_string,
2221
normalize_issue
23-
} from '../../form-utils.svelte.js';
22+
} from '../../form-utils.js';
2423

2524
/**
2625
* Merge client issues into server issues. Server issues are persisted unless
@@ -60,24 +59,9 @@ export function form(id) {
6059
const action = '?/remote=' + encodeURIComponent(action_id);
6160

6261
/**
63-
* By making this $state.raw() and creating a new object each time we update it,
64-
* all consumers along the update chain are properly invalidated.
6562
* @type {Record<string, string | string[] | File | File[]>}
6663
*/
67-
let input = $state.raw({});
68-
69-
// TODO 3.0: Remove versions state and related logic; it's a workaround for $derived not updating when created inside $effects
70-
/**
71-
* This allows us to update individual fields granularly
72-
* @type {Record<string, number>}
73-
*/
74-
const versions = $state({});
75-
76-
/**
77-
* This ensures that `{field.value()}` is updated even if the version hasn't been initialized
78-
* @type {Set<string>}
79-
*/
80-
const version_reads = new Set();
64+
let input = $state({});
8165

8266
/** @type {InternalRemoteFormIssue[]} */
8367
let raw_issues = $state.raw([]);
@@ -101,16 +85,6 @@ export function form(id) {
10185

10286
let submitted = false;
10387

104-
function update_all_versions() {
105-
for (const path of version_reads) {
106-
versions[path] ??= 0;
107-
}
108-
109-
for (const key of Object.keys(versions)) {
110-
versions[key] += 1;
111-
}
112-
}
113-
11488
/**
11589
* @param {FormData} form_data
11690
* @returns {Record<string, any>}
@@ -235,8 +209,6 @@ export function form(id) {
235209
if (issues.$) {
236210
release_overrides(updates);
237211
} else {
238-
update_all_versions();
239-
240212
if (form_result.refreshes) {
241213
refresh_queries(form_result.refreshes, updates);
242214
} else {
@@ -386,7 +358,7 @@ export function form(id) {
386358
}
387359
}
388360

389-
input = set_nested_value(input, name, value);
361+
set_nested_value(input, name, value);
390362
} else if (is_file) {
391363
if (DEV && element.multiple) {
392364
throw new Error(
@@ -397,7 +369,7 @@ export function form(id) {
397369
const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];
398370

399371
if (file) {
400-
input = set_nested_value(input, name, file);
372+
set_nested_value(input, name, file);
401373
} else {
402374
// Remove the property by setting to undefined and clean up
403375
const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
@@ -409,7 +381,7 @@ export function form(id) {
409381
delete current[path_parts[path_parts.length - 1]];
410382
}
411383
} else {
412-
input = set_nested_value(
384+
set_nested_value(
413385
input,
414386
name,
415387
element.type === 'checkbox' && !element.checked ? null : element.value
@@ -418,17 +390,7 @@ export function form(id) {
418390

419391
name = name.replace(/^[nb]:/, '');
420392

421-
versions[name] ??= 0;
422-
versions[name] += 1;
423-
424-
const path = split_path(name);
425-
426-
while (path.pop() !== undefined) {
427-
const name = build_path_string(path);
428-
429-
versions[name] ??= 0;
430-
versions[name] += 1;
431-
}
393+
touched[name] = true;
432394
});
433395

434396
form.addEventListener('reset', async () => {
@@ -437,7 +399,6 @@ export function form(id) {
437399
await tick();
438400

439401
input = convert_formdata(new FormData(form));
440-
update_all_versions();
441402
});
442403

443404
return () => {
@@ -530,28 +491,14 @@ export function form(id) {
530491
create_field_proxy(
531492
{},
532493
() => input,
533-
(path) => {
534-
version_reads.add(path);
535-
versions[path];
536-
},
537494
(path, value) => {
538495
if (path.length === 0) {
539496
input = value;
540-
update_all_versions();
541497
} else {
542-
input = deep_set(input, path.map(String), value);
498+
deep_set(input, path.map(String), value);
543499

544500
const key = build_path_string(path);
545-
versions[key] ??= 0;
546-
versions[key] += 1;
547501
touched[key] = true;
548-
549-
const parent_path = path.slice();
550-
while (parent_path.pop() !== undefined) {
551-
const parent_key = build_path_string(parent_path);
552-
versions[parent_key] ??= 0;
553-
versions[parent_key] += 1;
554-
}
555502
}
556503
},
557504
() => issues

packages/kit/src/runtime/form-utils.svelte.js renamed to packages/kit/src/runtime/form-utils.js

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */
44

55
import { DEV } from 'esm-env';
6-
import * as svelte from 'svelte';
7-
// Svelte 4 and under don't have `untrack` - you'll not be able to use remote functions with Svelte 4 but this will still be loaded
8-
const untrack = svelte.untrack ?? ((value) => value());
96

107
/**
11-
* Sets a value in a nested object using a path string, not mutating the original object but returning a new object
8+
* Sets a value in a nested object using a path string, mutating the original object
129
* @param {Record<string, any>} object
1310
* @param {string} path_string
1411
* @param {any} value
@@ -22,7 +19,7 @@ export function set_nested_value(object, path_string, value) {
2219
value = value === 'on';
2320
}
2421

25-
return deep_set(object, split_path(path_string), value);
22+
deep_set(object, split_path(path_string), value);
2623
}
2724

2825
/**
@@ -31,7 +28,7 @@ export function set_nested_value(object, path_string, value) {
3128
*/
3229
export function convert_formdata(data) {
3330
/** @type {Record<string, any>} */
34-
let result = Object.create(null); // guard against prototype pollution
31+
const result = {};
3532

3633
for (let key of data.keys()) {
3734
if (key.startsWith('sveltekit:')) {
@@ -61,7 +58,7 @@ export function convert_formdata(data) {
6158
values = values.map((v) => v === 'on');
6259
}
6360

64-
result = set_nested_value(result, key, is_array ? values : values[0]);
61+
set_nested_value(result, key, is_array ? values : values[0]);
6562
}
6663

6764
return result;
@@ -81,18 +78,32 @@ export function split_path(path) {
8178
}
8279

8380
/**
84-
* Sets a value in a nested object using an array of keys.
85-
* Does not mutate the original object; returns a new object.
81+
* Check if a property key is dangerous and could lead to prototype pollution
82+
* @param {string} key
83+
*/
84+
function check_prototype_pollution(key) {
85+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
86+
throw new Error(
87+
`Invalid key "${key}"` +
88+
(DEV ? ': This key is not allowed to prevent prototype pollution.' : '')
89+
);
90+
}
91+
}
92+
93+
/**
94+
* Sets a value in a nested object using an array of keys, mutating the original object.
8695
* @param {Record<string, any>} object
8796
* @param {string[]} keys
8897
* @param {any} value
8998
*/
9099
export function deep_set(object, keys, value) {
91-
const result = Object.assign(Object.create(null), object); // guard against prototype pollution
92-
let current = result;
100+
let current = object;
93101

94102
for (let i = 0; i < keys.length - 1; i += 1) {
95103
const key = keys[i];
104+
105+
check_prototype_pollution(key);
106+
96107
const is_array = /^\d+$/.test(keys[i + 1]);
97108
const exists = key in current;
98109
const inner = current[key];
@@ -101,18 +112,16 @@ export function deep_set(object, keys, value) {
101112
throw new Error(`Invalid array key ${keys[i + 1]}`);
102113
}
103114

104-
current[key] = is_array
105-
? exists
106-
? [...inner]
107-
: []
108-
: // guard against prototype pollution
109-
Object.assign(Object.create(null), inner);
115+
if (!exists) {
116+
current[key] = is_array ? [] : {};
117+
}
110118

111119
current = current[key];
112120
}
113121

114-
current[keys[keys.length - 1]] = value;
115-
return result;
122+
const final_key = keys[keys.length - 1];
123+
check_prototype_pollution(final_key);
124+
current[final_key] = value;
116125
}
117126

118127
/**
@@ -196,18 +205,14 @@ export function deep_get(object, path) {
196205
* Creates a proxy-based field accessor for form data
197206
* @param {any} target - Function or empty POJO
198207
* @param {() => Record<string, any>} get_input - Function to get current input data
199-
* @param {(path: string) => void} depend - Function to make an effect depend on a specific field
200208
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
201209
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
202210
* @param {(string | number)[]} path - Current access path
203211
* @returns {any} Proxy object with name(), value(), and issues() methods
204212
*/
205-
export function create_field_proxy(target, get_input, depend, set_input, get_issues, path = []) {
206-
const path_string = build_path_string(path);
207-
213+
export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
208214
const get_value = () => {
209-
depend(path_string);
210-
return untrack(() => deep_get(get_input(), path));
215+
return deep_get(get_input(), path);
211216
};
212217

213218
return new Proxy(target, {
@@ -216,7 +221,7 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
216221

217222
// Handle array access like jobs[0]
218223
if (/^\d+$/.test(prop)) {
219-
return create_field_proxy({}, get_input, depend, set_input, get_issues, [
224+
return create_field_proxy({}, get_input, set_input, get_issues, [
220225
...path,
221226
parseInt(prop, 10)
222227
]);
@@ -229,17 +234,11 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
229234
set_input(path, newValue);
230235
return newValue;
231236
};
232-
return create_field_proxy(set_func, get_input, depend, set_input, get_issues, [
233-
...path,
234-
prop
235-
]);
237+
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
236238
}
237239

238240
if (prop === 'value') {
239-
return create_field_proxy(get_value, get_input, depend, set_input, get_issues, [
240-
...path,
241-
prop
242-
]);
241+
return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]);
243242
}
244243

245244
if (prop === 'issues' || prop === 'allIssues') {
@@ -259,10 +258,7 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
259258
}));
260259
};
261260

262-
return create_field_proxy(issues_func, get_input, depend, set_input, get_issues, [
263-
...path,
264-
prop
265-
]);
261+
return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
266262
}
267263

268264
if (prop === 'as') {
@@ -411,14 +407,11 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
411407
});
412408
};
413409

414-
return create_field_proxy(as_func, get_input, depend, set_input, get_issues, [
415-
...path,
416-
'as'
417-
]);
410+
return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
418411
}
419412

420413
// Handle property access (nested fields)
421-
return create_field_proxy({}, get_input, depend, set_input, get_issues, [...path, prop]);
414+
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
422415
}
423416
});
424417
}

packages/kit/src/runtime/form-utils.spec.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'vitest';
2-
import { convert_formdata, split_path } from './form-utils.svelte.js';
2+
import { convert_formdata, split_path } from './form-utils.js';
33

44
describe('split_path', () => {
55
const good = [
@@ -73,4 +73,20 @@ describe('convert_formdata', () => {
7373
}
7474
});
7575
});
76+
77+
const pollution_attacks = [
78+
'__proto__.polluted',
79+
'constructor.polluted',
80+
'prototype.polluted',
81+
'user.__proto__.polluted',
82+
'user.constructor.polluted'
83+
];
84+
85+
for (const attack of pollution_attacks) {
86+
test(`prevents prototype pollution: ${attack}`, () => {
87+
const data = new FormData();
88+
data.append(attack, 'bad');
89+
expect(() => convert_formdata(data)).toThrow(/Invalid key "/);
90+
});
91+
}
7692
});

0 commit comments

Comments
 (0)