@timber-js/app 0.2.0-alpha.87 → 0.2.0-alpha.89
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/LICENSE +8 -0
- package/dist/client/index.d.ts +44 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +7 -44
- package/dist/client/link.d.ts.map +1 -1
- package/dist/config-types.d.ts +39 -0
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +48 -0
- package/dist/fonts/bundle.d.ts.map +1 -0
- package/dist/fonts/dev-middleware.d.ts +22 -0
- package/dist/fonts/dev-middleware.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +138 -0
- package/dist/fonts/pipeline.d.ts.map +1 -0
- package/dist/fonts/transform.d.ts +72 -0
- package/dist/fonts/transform.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +45 -1
- package/dist/fonts/types.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +59 -0
- package/dist/fonts/virtual-modules.d.ts.map +1 -0
- package/dist/index.js +753 -575
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +16 -83
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +8 -0
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +7 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/index.js +158 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/route-matcher.d.ts +7 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/sensitive-fields.d.ts +74 -0
- package/dist/server/sensitive-fields.d.ts.map +1 -0
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/index.ts +77 -1
- package/src/client/link.tsx +15 -65
- package/src/config-types.ts +39 -0
- package/src/config-validation.ts +7 -3
- package/src/fonts/bundle.ts +142 -0
- package/src/fonts/dev-middleware.ts +74 -0
- package/src/fonts/pipeline.ts +275 -0
- package/src/fonts/transform.ts +353 -0
- package/src/fonts/types.ts +50 -1
- package/src/fonts/virtual-modules.ts +159 -0
- package/src/plugins/entries.ts +37 -0
- package/src/plugins/fonts.ts +102 -704
- package/src/plugins/routing.ts +6 -5
- package/src/server/action-client.ts +34 -4
- package/src/server/action-handler.ts +32 -2
- package/src/server/route-matcher.ts +7 -0
- package/src/server/rsc-entry/index.ts +19 -3
- package/src/server/sensitive-fields.ts +230 -0
package/src/plugins/routing.ts
CHANGED
|
@@ -172,7 +172,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
172
172
|
rescan();
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
return generateManifestModule(ctx.routeTree
|
|
175
|
+
return generateManifestModule(ctx.routeTree!, ctx.root);
|
|
176
176
|
},
|
|
177
177
|
|
|
178
178
|
/**
|
|
@@ -201,7 +201,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
201
201
|
devServer.watcher.add(ctx.appDir);
|
|
202
202
|
|
|
203
203
|
/** Snapshot of the last generated manifest, used to detect structural changes. */
|
|
204
|
-
let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
|
|
204
|
+
let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
207
|
* Handle a route-significant file being added or removed.
|
|
@@ -212,7 +212,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
212
212
|
if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
|
|
213
213
|
|
|
214
214
|
rescan();
|
|
215
|
-
lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
|
|
215
|
+
lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
|
|
216
216
|
invalidateManifest(devServer);
|
|
217
217
|
};
|
|
218
218
|
|
|
@@ -229,7 +229,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
229
229
|
if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
|
|
230
230
|
|
|
231
231
|
rescan();
|
|
232
|
-
const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : '';
|
|
232
|
+
const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
|
|
233
233
|
if (newManifest !== lastManifest) {
|
|
234
234
|
lastManifest = newManifest;
|
|
235
235
|
invalidateManifest(devServer);
|
|
@@ -275,7 +275,7 @@ function invalidateManifest(server: ViteDevServer): void {
|
|
|
275
275
|
* The output is a default-exported object containing the serialized route tree.
|
|
276
276
|
* All file references use absolute paths (required for virtual modules).
|
|
277
277
|
*/
|
|
278
|
-
function generateManifestModule(tree: RouteTree): string {
|
|
278
|
+
function generateManifestModule(tree: RouteTree, viteRoot: string): string {
|
|
279
279
|
const imports: string[] = [];
|
|
280
280
|
let importIndex = 0;
|
|
281
281
|
|
|
@@ -477,6 +477,7 @@ function generateManifestModule(tree: RouteTree): string {
|
|
|
477
477
|
...imports,
|
|
478
478
|
'',
|
|
479
479
|
'const manifest = {',
|
|
480
|
+
` viteRoot: ${JSON.stringify(viteRoot)},`,
|
|
480
481
|
proxyLine,
|
|
481
482
|
globalErrorLine,
|
|
482
483
|
rewritesLine,
|
|
@@ -131,6 +131,13 @@ interface ActionClientConfig<TCtx> {
|
|
|
131
131
|
middleware?: ActionMiddleware<TCtx> | ActionMiddleware<Record<string, unknown>>[];
|
|
132
132
|
/** Max file size in bytes. Files exceeding this are rejected with validation errors. */
|
|
133
133
|
fileSizeLimit?: number;
|
|
134
|
+
/**
|
|
135
|
+
* Override the sensitive-field deny-list for this action client.
|
|
136
|
+
* See `SensitiveFieldsOption` in `./sensitive-fields.ts`. Per-action config
|
|
137
|
+
* takes precedence over the global `forms.stripSensitiveFields` option in
|
|
138
|
+
* `timber.config.ts`. See design/08-forms-and-actions.md and TIM-816.
|
|
139
|
+
*/
|
|
140
|
+
stripSensitiveFields?: SensitiveFieldsOption;
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
/** Intermediate builder returned by createActionClient(). */
|
|
@@ -217,6 +224,12 @@ import { parseFormData } from './form-data.js';
|
|
|
217
224
|
import { formatSize } from '../utils/format.js';
|
|
218
225
|
import { isDebug, isDevMode } from './debug.js';
|
|
219
226
|
import { RedirectSignal, DenySignal } from './primitives.js';
|
|
227
|
+
import {
|
|
228
|
+
stripSensitiveFields,
|
|
229
|
+
resolveSensitivePredicate,
|
|
230
|
+
getGlobalSensitiveFieldsConfig,
|
|
231
|
+
type SensitiveFieldsOption,
|
|
232
|
+
} from './sensitive-fields.js';
|
|
220
233
|
|
|
221
234
|
/**
|
|
222
235
|
* Extract validation errors from a schema error.
|
|
@@ -340,6 +353,25 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
340
353
|
rawInput = args[0];
|
|
341
354
|
}
|
|
342
355
|
|
|
356
|
+
// Resolve the sensitive-field stripping predicate once per invocation.
|
|
357
|
+
// Precedence: per-action (config.stripSensitiveFields) > global
|
|
358
|
+
// (forms.stripSensitiveFields from timber.config.ts) > built-in deny-list.
|
|
359
|
+
// See TIM-816.
|
|
360
|
+
const sensitivePredicate = resolveSensitivePredicate(
|
|
361
|
+
config.stripSensitiveFields,
|
|
362
|
+
getGlobalSensitiveFieldsConfig()
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
// Capture a "safe-to-echo" snapshot of the raw input once. Files are
|
|
366
|
+
// stripped (can't serialize, shouldn't echo back) and sensitive fields
|
|
367
|
+
// (passwords, tokens, CVV, etc.) are removed before they would land
|
|
368
|
+
// in the RSC payload → client form `defaultValue` → DOM.
|
|
369
|
+
const buildSubmittedValues = (): Record<string, unknown> | undefined => {
|
|
370
|
+
const withoutFiles = stripFiles(rawInput);
|
|
371
|
+
if (withoutFiles === undefined) return undefined;
|
|
372
|
+
return stripSensitiveFields(withoutFiles, sensitivePredicate);
|
|
373
|
+
};
|
|
374
|
+
|
|
343
375
|
// Validate file sizes before schema validation.
|
|
344
376
|
if (config.fileSizeLimit !== undefined && rawInput && typeof rawInput === 'object') {
|
|
345
377
|
const fileSizeErrors = validateFileSizes(
|
|
@@ -347,14 +379,12 @@ export function createActionClient<TCtx = Record<string, never>>(
|
|
|
347
379
|
config.fileSizeLimit
|
|
348
380
|
);
|
|
349
381
|
if (fileSizeErrors) {
|
|
350
|
-
|
|
351
|
-
return { validationErrors: fileSizeErrors, submittedValues };
|
|
382
|
+
return { validationErrors: fileSizeErrors, submittedValues: buildSubmittedValues() };
|
|
352
383
|
}
|
|
353
384
|
}
|
|
354
385
|
|
|
355
386
|
// Capture submitted values for repopulation on validation failure.
|
|
356
|
-
|
|
357
|
-
const submittedValues = schema ? stripFiles(rawInput) : undefined;
|
|
387
|
+
const submittedValues = schema ? buildSubmittedValues() : undefined;
|
|
358
388
|
|
|
359
389
|
// Validate with schema if provided
|
|
360
390
|
let input: TInput;
|
|
@@ -30,6 +30,12 @@ import {
|
|
|
30
30
|
import { handleActionError } from './action-client.js';
|
|
31
31
|
import { enforceBodyLimits, enforceFieldLimit, type BodyLimitsConfig } from './body-limits.js';
|
|
32
32
|
import { parseFormData } from './form-data.js';
|
|
33
|
+
import {
|
|
34
|
+
stripSensitiveFields,
|
|
35
|
+
resolveSensitivePredicate,
|
|
36
|
+
getGlobalSensitiveFieldsConfig,
|
|
37
|
+
type SensitiveFieldsOption,
|
|
38
|
+
} from './sensitive-fields.js';
|
|
33
39
|
import type { FormFlashData } from './form-flash.js';
|
|
34
40
|
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
35
41
|
import { logActionError } from './logger.js';
|
|
@@ -44,6 +50,12 @@ export interface ActionDispatchConfig {
|
|
|
44
50
|
revalidateRenderer?: RevalidateRenderer;
|
|
45
51
|
/** Body size limits (from timber.config.ts). */
|
|
46
52
|
bodyLimits?: BodyLimitsConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Override the sensitive-field deny-list for the no-JS form POST path.
|
|
55
|
+
* Defaults to the global `forms.stripSensitiveFields` from `timber.config.ts`.
|
|
56
|
+
* See `SensitiveFieldsOption` in `./sensitive-fields.ts` and TIM-816.
|
|
57
|
+
*/
|
|
58
|
+
sensitiveFields?: SensitiveFieldsOption;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
@@ -295,8 +307,14 @@ async function handleFormAction(
|
|
|
295
307
|
}
|
|
296
308
|
|
|
297
309
|
// Capture submitted values for re-render on validation failure.
|
|
298
|
-
// Parse before decodeAction consumes the FormData
|
|
299
|
-
|
|
310
|
+
// Parse before decodeAction consumes the FormData, then strip sensitive
|
|
311
|
+
// fields (passwords, tokens, CVV, etc.) so they are never rendered back
|
|
312
|
+
// into the HTML as `defaultValue` attributes. See TIM-816.
|
|
313
|
+
const sensitivePredicate = resolveSensitivePredicate(
|
|
314
|
+
config.sensitiveFields,
|
|
315
|
+
getGlobalSensitiveFieldsConfig()
|
|
316
|
+
);
|
|
317
|
+
const submittedValues = stripSensitiveFields(parseFormData(formData), sensitivePredicate);
|
|
300
318
|
|
|
301
319
|
// decodeAction resolves the action function from the form data's hidden fields.
|
|
302
320
|
// It returns a bound function with the form data already applied.
|
|
@@ -338,5 +356,17 @@ async function handleFormAction(
|
|
|
338
356
|
// This handles both success ({ data }) and validation failure
|
|
339
357
|
// ({ validationErrors, submittedValues }) — the form is the single source of truth.
|
|
340
358
|
const actionResult = result.actionResult as FormFlashData;
|
|
359
|
+
|
|
360
|
+
// Defense-in-depth: strip sensitive fields from `actionResult.submittedValues`
|
|
361
|
+
// even if the action already built it. `createActionClient` strips internally,
|
|
362
|
+
// but raw `'use server'` functions that manually return `{ submittedValues }`
|
|
363
|
+
// are not covered by the inner strip. See TIM-816.
|
|
364
|
+
if (actionResult && actionResult.submittedValues) {
|
|
365
|
+
actionResult.submittedValues = stripSensitiveFields(
|
|
366
|
+
actionResult.submittedValues,
|
|
367
|
+
sensitivePredicate
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
341
371
|
return { rerender: actionResult };
|
|
342
372
|
}
|
|
@@ -68,6 +68,13 @@ export interface ManifestSegmentNode {
|
|
|
68
68
|
/** The manifest shape from virtual:timber-route-manifest. */
|
|
69
69
|
export interface ManifestRoot {
|
|
70
70
|
root: ManifestSegmentNode;
|
|
71
|
+
/**
|
|
72
|
+
* Absolute path to the Vite project root, captured at build/load time.
|
|
73
|
+
* Used by dev-only features (e.g., dev error page frame classification)
|
|
74
|
+
* that need a correct project root even when CWD differs (e.g., monorepo
|
|
75
|
+
* custom root). See TIM-807 / TIM-808.
|
|
76
|
+
*/
|
|
77
|
+
viteRoot: string;
|
|
71
78
|
proxy?: ManifestFile;
|
|
72
79
|
/**
|
|
73
80
|
* Global error page: app/global-error.{tsx,ts,jsx,js}
|
|
@@ -192,6 +192,18 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
192
192
|
setDebugFromConfig(true);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// Wire the global sensitive-field stripping config so `createActionClient`
|
|
196
|
+
// instances in user code pick up `forms.stripSensitiveFields` from
|
|
197
|
+
// `timber.config.ts` without needing to plumb config through each action.
|
|
198
|
+
// See TIM-816 and design/08-forms-and-actions.md §"Validation errors".
|
|
199
|
+
const formsConfig = (runtimeConfig as Record<string, unknown>).forms as
|
|
200
|
+
| { stripSensitiveFields?: import('../sensitive-fields.js').SensitiveFieldsOption }
|
|
201
|
+
| undefined;
|
|
202
|
+
if (formsConfig) {
|
|
203
|
+
const { setGlobalSensitiveFieldsConfig } = await import('../sensitive-fields.js');
|
|
204
|
+
setGlobalSensitiveFieldsConfig(formsConfig.stripSensitiveFields);
|
|
205
|
+
}
|
|
206
|
+
|
|
195
207
|
// Two separate flags for two different security levels:
|
|
196
208
|
//
|
|
197
209
|
// isDev (isDevMode) — gates client-visible behavior: dev error pages with
|
|
@@ -324,9 +336,12 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
324
336
|
manifest.globalError,
|
|
325
337
|
// Project root for dev error page frame classification.
|
|
326
338
|
// Not in runtimeConfig (TIM-787: leaked to client bundles).
|
|
327
|
-
// manifest.
|
|
328
|
-
// CWD differs from project root (e.g., monorepo custom root)
|
|
329
|
-
|
|
339
|
+
// manifest.viteRoot is the resolved Vite root string — correct even
|
|
340
|
+
// when CWD differs from project root (e.g., monorepo custom root)
|
|
341
|
+
// (TIM-807). Passing manifest.root (segment tree object) here would
|
|
342
|
+
// stringify to "[object Object]" and misclassify app frames as
|
|
343
|
+
// internal, dropping source locations from the dev error page (TIM-808).
|
|
344
|
+
isDev ? manifest.viteRoot : undefined
|
|
330
345
|
),
|
|
331
346
|
renderDenyFallback: async (deny, req, responseHeaders) => {
|
|
332
347
|
// Render the deny page (403.tsx, 404.tsx, etc.) for DenySignals
|
|
@@ -389,6 +404,7 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
|
|
|
389
404
|
bodyLimits: {
|
|
390
405
|
limits: (runtimeConfig as Record<string, unknown>).limits as BodyLimitsConfig['limits'],
|
|
391
406
|
},
|
|
407
|
+
sensitiveFields: formsConfig?.stripSensitiveFields,
|
|
392
408
|
revalidateRenderer: async (path: string) => {
|
|
393
409
|
// Build the React element tree for the route at `path`.
|
|
394
410
|
// Returns the element tree (not serialized) so the action handler can
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sensitive field stripping — removes password/token/CVV-style fields
|
|
3
|
+
* from form values before they are echoed back to the client as
|
|
4
|
+
* `submittedValues` for form repopulation.
|
|
5
|
+
*
|
|
6
|
+
* Applied to both action paths:
|
|
7
|
+
* - With-JS action path: `createActionClient()` in `action-client.ts`
|
|
8
|
+
* - No-JS form POST path: `handleFormAction()` in `action-handler.ts`
|
|
9
|
+
*
|
|
10
|
+
* Why: on a validation failure, timber echoes submitted form values back so
|
|
11
|
+
* the user doesn't have to re-type everything. Without filtering, plaintext
|
|
12
|
+
* passwords / credit-card numbers / TOTP codes would travel through the RSC
|
|
13
|
+
* stream (with-JS) or land in the HTML as `defaultValue` attributes (no-JS)
|
|
14
|
+
* — ending up in browser history, proxy logs, disk caches, and the
|
|
15
|
+
* back-forward cache.
|
|
16
|
+
*
|
|
17
|
+
* Safe by default: the built-in deny-list is applied unconditionally unless
|
|
18
|
+
* the user explicitly opts out via `forms.stripSensitiveFields: false` in
|
|
19
|
+
* `timber.config.ts` or per-action via `createActionClient({ stripSensitiveFields: false })`.
|
|
20
|
+
*
|
|
21
|
+
* See design/08-forms-and-actions.md §"Validation errors"
|
|
22
|
+
* See design/13-security.md §"Sensitive field stripping"
|
|
23
|
+
* See TIM-816
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { isDebug } from './debug.js';
|
|
27
|
+
|
|
28
|
+
// ─── Public types ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How to strip sensitive fields from `submittedValues`.
|
|
32
|
+
*
|
|
33
|
+
* - `true` / `undefined` — use the built-in deny-list (default, safe).
|
|
34
|
+
* - `false` — do not strip anything (dev convenience; never do this in prod).
|
|
35
|
+
* - `string[]` — additional field names to strip, merged with the built-in list.
|
|
36
|
+
* - `(name) => boolean` — custom predicate, fully replaces the built-in list.
|
|
37
|
+
* Return `true` to strip, `false` to keep. The `name` argument is the raw
|
|
38
|
+
* (un-normalized) field name as it appeared in the submitted form.
|
|
39
|
+
*/
|
|
40
|
+
export type SensitiveFieldsOption = boolean | readonly string[] | ((name: string) => boolean);
|
|
41
|
+
|
|
42
|
+
// ─── Built-in deny-list ──────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Substring patterns matched against the normalized field name.
|
|
46
|
+
* Normalization = lowercase + strip `_` and `-`.
|
|
47
|
+
*
|
|
48
|
+
* Any field whose normalized name *contains* one of these strings is
|
|
49
|
+
* considered sensitive. Entries like `currentPassword`, `passwordConfirmation`,
|
|
50
|
+
* and `user.password` all match via the `password` substring.
|
|
51
|
+
*/
|
|
52
|
+
const BUILTIN_SUBSTRING_PATTERNS: readonly string[] = [
|
|
53
|
+
'password',
|
|
54
|
+
'passwd',
|
|
55
|
+
'pwd',
|
|
56
|
+
'secret',
|
|
57
|
+
'apikey',
|
|
58
|
+
'accesstoken',
|
|
59
|
+
'refreshtoken',
|
|
60
|
+
'cvv',
|
|
61
|
+
'cvc',
|
|
62
|
+
'cardnumber',
|
|
63
|
+
'cardcvc',
|
|
64
|
+
'ssn',
|
|
65
|
+
'socialsecuritynumber',
|
|
66
|
+
'otp',
|
|
67
|
+
'totp',
|
|
68
|
+
'mfacode',
|
|
69
|
+
'twofactorcode',
|
|
70
|
+
'privatekey',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Exact matches against the normalized field name. These are field names that
|
|
75
|
+
* are too short or too common to substring-match safely. e.g. `token` alone
|
|
76
|
+
* would match `csrfToken`, which is not sensitive — so `token` is exact-only,
|
|
77
|
+
* while legitimate token fields are covered by `accesstoken` / `refreshtoken`.
|
|
78
|
+
*/
|
|
79
|
+
const BUILTIN_EXACT_PATTERNS: readonly string[] = ['token'];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalize a field name for deny-list comparison.
|
|
83
|
+
* Lowercases the string and strips `_` and `-` so camelCase, snake_case, and
|
|
84
|
+
* kebab-case variants all compare equal (`api_key` / `apiKey` / `api-key` →
|
|
85
|
+
* `apikey`).
|
|
86
|
+
*/
|
|
87
|
+
function normalize(name: string): string {
|
|
88
|
+
let out = '';
|
|
89
|
+
for (let i = 0; i < name.length; i++) {
|
|
90
|
+
const ch = name.charCodeAt(i);
|
|
91
|
+
if (ch === 0x5f /* _ */ || ch === 0x2d /* - */) continue;
|
|
92
|
+
// A-Z → a-z
|
|
93
|
+
if (ch >= 0x41 && ch <= 0x5a) {
|
|
94
|
+
out += String.fromCharCode(ch + 32);
|
|
95
|
+
} else {
|
|
96
|
+
out += name[i];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check whether a name matches the built-in deny-list (with optional extras).
|
|
104
|
+
* Extras are merged into the substring pattern list after normalization.
|
|
105
|
+
*/
|
|
106
|
+
function isBuiltinSensitive(name: string, extras?: readonly string[]): boolean {
|
|
107
|
+
const normalized = normalize(name);
|
|
108
|
+
if (BUILTIN_EXACT_PATTERNS.includes(normalized)) return true;
|
|
109
|
+
for (const pattern of BUILTIN_SUBSTRING_PATTERNS) {
|
|
110
|
+
if (normalized.includes(pattern)) return true;
|
|
111
|
+
}
|
|
112
|
+
if (extras && extras.length > 0) {
|
|
113
|
+
for (const extra of extras) {
|
|
114
|
+
const normExtra = normalize(extra);
|
|
115
|
+
if (normExtra.length === 0) continue;
|
|
116
|
+
if (normalized.includes(normExtra)) return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Predicate resolution ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A resolved predicate: `null` means "don't strip anything" (the option was
|
|
126
|
+
* explicitly `false`). Otherwise a function from raw field name → boolean.
|
|
127
|
+
*/
|
|
128
|
+
export type ResolvedSensitivePredicate = ((name: string) => boolean) | null;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a `SensitiveFieldsOption` into a concrete predicate.
|
|
132
|
+
* Precedence: per-action > global > built-in default.
|
|
133
|
+
*
|
|
134
|
+
* - Per-action `undefined` → fall back to global.
|
|
135
|
+
* - Global `undefined` → use built-in list.
|
|
136
|
+
* - Either level set to `false` → disable stripping entirely (returns `null`).
|
|
137
|
+
* - `true` → built-in list.
|
|
138
|
+
* - `string[]` → built-in ∪ extras.
|
|
139
|
+
* - function → custom, replaces the built-in list entirely.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveSensitivePredicate(
|
|
142
|
+
perAction: SensitiveFieldsOption | undefined,
|
|
143
|
+
global: SensitiveFieldsOption | undefined
|
|
144
|
+
): ResolvedSensitivePredicate {
|
|
145
|
+
const chosen = perAction !== undefined ? perAction : global;
|
|
146
|
+
|
|
147
|
+
if (chosen === false) return null;
|
|
148
|
+
if (chosen === undefined || chosen === true) {
|
|
149
|
+
return (name) => isBuiltinSensitive(name);
|
|
150
|
+
}
|
|
151
|
+
if (typeof chosen === 'function') {
|
|
152
|
+
return chosen;
|
|
153
|
+
}
|
|
154
|
+
// Array of extra names merged with the built-in list.
|
|
155
|
+
const extras = chosen;
|
|
156
|
+
return (name) => isBuiltinSensitive(name, extras);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Module-level global config ──────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
let globalConfig: SensitiveFieldsOption | undefined;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Set the global `forms.stripSensitiveFields` config from `timber.config.ts`.
|
|
165
|
+
* Called once at startup from `rsc-entry`.
|
|
166
|
+
*/
|
|
167
|
+
export function setGlobalSensitiveFieldsConfig(option: SensitiveFieldsOption | undefined): void {
|
|
168
|
+
globalConfig = option;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Read the global `forms.stripSensitiveFields` config. */
|
|
172
|
+
export function getGlobalSensitiveFieldsConfig(): SensitiveFieldsOption | undefined {
|
|
173
|
+
return globalConfig;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Stripping ───────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
// One warning per field name per process — prevents log spam when a form is
|
|
179
|
+
// submitted many times in dev mode.
|
|
180
|
+
const warnedFields = new Set<string>();
|
|
181
|
+
|
|
182
|
+
function warnStripped(name: string): void {
|
|
183
|
+
if (!isDebug()) return;
|
|
184
|
+
if (warnedFields.has(name)) return;
|
|
185
|
+
warnedFields.add(name);
|
|
186
|
+
console.warn(
|
|
187
|
+
`[timber] stripped sensitive field "${name}" from submittedValues. ` +
|
|
188
|
+
`Override via forms.stripSensitiveFields in timber.config.ts.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Walk an object (recursively) and return a copy with every key matching
|
|
194
|
+
* `predicate` removed. Nested objects like `{ user: { password: '...' } }`
|
|
195
|
+
* are handled — `user.password` is stripped while other `user.*` fields remain.
|
|
196
|
+
*
|
|
197
|
+
* - Arrays are walked element-wise (object entries inside arrays are cleaned).
|
|
198
|
+
* - Non-plain values (strings, numbers, Files, Dates, etc.) are returned as-is.
|
|
199
|
+
* - When a stripped key is encountered, it is omitted from the result entirely
|
|
200
|
+
* — we do NOT set it to an empty string, because that would overwrite a
|
|
201
|
+
* valid `defaultValue` the form author might have set.
|
|
202
|
+
*/
|
|
203
|
+
export function stripSensitiveFields<T>(value: T, predicate: ResolvedSensitivePredicate): T {
|
|
204
|
+
// Null predicate = stripping disabled entirely.
|
|
205
|
+
if (predicate === null) return value;
|
|
206
|
+
if (value === null || value === undefined) return value;
|
|
207
|
+
if (typeof value !== 'object') return value;
|
|
208
|
+
if (value instanceof File || value instanceof Date) return value;
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
return value.map((item) => stripSensitiveFields(item, predicate)) as unknown as T;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const result: Record<string, unknown> = {};
|
|
215
|
+
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
|
216
|
+
if (predicate(key)) {
|
|
217
|
+
warnStripped(key);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
result[key] = stripSensitiveFields(nested, predicate);
|
|
221
|
+
}
|
|
222
|
+
return result as unknown as T;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Test helpers ────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** Reset the "warned once" cache. Exposed for tests. */
|
|
228
|
+
export function __resetSensitiveFieldsWarnings(): void {
|
|
229
|
+
warnedFields.clear();
|
|
230
|
+
}
|