Skip to content

fix(console): PublicFormsPage 的 redirect 授权门改问契约,越契约值在保存时刻被拒 (#4990) - #5008

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4990-redirect-authoring-door
Aug 17, 2026
Merged

fix(console): PublicFormsPage 的 redirect 授权门改问契约,越契约值在保存时刻被拒 (#4990)#5008
yinlianghui merged 1 commit into
mainfrom
claude/issue-4990-redirect-authoring-door

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4990

问题

apps/console/src/pages/developer/PublicFormsPage.tsxsaveEdit redirect 分支只验一件事 —— 字段非空,然后 client.meta.saveItem('view', …)

objectstack#7496(2026-08-11 裁定,objectstack#7657 落 spec,本仓 @objectstack/spec 17.0.0 GA pin 已覆盖)把 submitBehavior.url 裁为仅相对路径,并对七族越契约值各给一句 author-facing 处方:空值、前导 scheme(https:,同样地 javascript: / data:)、前导 //、反斜杠、空白或 C0/DEL 控制字符、非良构 token 的花括号形状、不以 / 开头。

这道门只执行了其中第一族。管理员可以输入 https://example.com/thanksjavascript:alert(1),得不到任何提示 —— 而这个界面自己刚教过他这个值可以用:字段是 type="url"(该 type 自己认的「合法」恰是本键拒绝的绝对 URL),占位符写着 https://example.com/thanks。「declared = enforced」在这里是反的:授权门比它所授权的 schema 更宽松。

修法:复用同一次解析,不写第二份规则

按卡面形状,判定复用 #4992(#4190 消费侧)落地的那条 spec-schema 解析路径:

  • apps/console/src/components/submitRedirect.ts 把其中的 shape 判定抽出为新导出 checkSubmitRedirectUrl(url),返回 schema 接受后的值或 spec 自己那句 refusal;resolveSubmitRedirect(消费侧)改为调用它再做插值替换 —— 行为不变,少一份代码。
  • 授权门在保存时刻调用它。越契约就把 spec 的处方渲染在字段旁(role="alert" + aria-invalid + aria-describedby),并且不保存;输入即清除,作者在他想提交的那一刻被纠正一次,而不是逐键被追问。
  • 保存的是 schema 回读的值,所以授权门与渲染侧不可能对同一个目的地有两种意见 —— 它们是同一次解析。

规则没有在对话框里重写一份:镜像拷贝在 spec 改动前的每次取值比较里都是绿的,正是 scripts/check-spec-symbol-derivation.mjs 要劝退的形状(objectstack#4115、objectui#4074/#4588/#4592 各付过一次)。裁定日后放宽,跟着 pin 走,这里不需要改。

附带两处:本地的 Redirect URL is required 去掉(空值本就是七族之一,一并走契约,作者读到的是「目的地长什么样」而不是「必填」);字段改回纯文本输入、占位符 /thanks、并给出规则提示 —— 它不该再教错值。

边界(照卡面办)

服务端到底拒不拒(卡面的开放点,已测)

拒。 只读判定,未起任何服务:packages/restPUT /meta/:type/:name 委托 protocol.saveMetaItem,其 spec-conformance gate 走 resolveOverlaySchemagetMetadataTypeSchema('view')(@objectstack/spec/kernel,与 console 打包的同一个 17.0.0 GA pin)。用 PublicFormsPage.saveEdit 实际构造的 body 直接跑这条 gate:

"/thanks" => ACCEPTS -> would persist
"/t/{{record.id}}" => ACCEPTS -> would persist
"https://example.com/thanks" => 422 invalid_metadata on: ["submitBehavior.url"]
"javascript:alert(1)" => 422 invalid_metadata on: ["submitBehavior.url"]
"//evil.example/x" => 422 invalid_metadata on: ["submitBehavior.url"]
"thanks" => 422 invalid_metadata on: ["submitBehavior.url"]
"" => 422 invalid_metadata on: ["submitBehavior.url"]

所以卡面「either way」里成立的是前一支:元数据语料不会积累这些值,本卡收口的是错误路径而非静默入库。这不削弱修法,反而是它的论据 —— 纠正现在出现在管理员能改的字段里,而不是一次失败的往返;而且门与服务端从构造上就同判,因为是同一个 schema。

未测的部分如实标注:这是在 gate 函数层面测的(服务端路由实际调用的那个函数与注册表),不是活的 HTTP 往返;某个部署的 spec 版本若与 GA pin 不同,结论随之不同。另有一处顺带观察但未能证实,故不据此立 issue:上面的 probe 必须补 viewKind 才能让 body 落到 formOverlay 分支,缺它时同一条 gate 会对本页任何保存都 422(与 url 无关)—— 但本页保存的是 getItems 读回的 it.spec,其真实形状要有真实存量数据才能判定,我没有。

验证

  • pnpm exec vitest run apps/console/54 files / 647 tests 全绿(含新增 15 + 扩充 20)。
  • 仓根 pnpm exec turbo run type-check --concurrency=281/81 successful
  • node scripts/check-control-bytes.mjs → OK(4465 files);check-spec-symbol-derivation.mjs / 两个 changeset 门 → OK。改动文件 eslint:0 error,warning 数与基线逐一相同(14 → 14,均为既有 any / useEffect)。
  • 测试用例里的控制字符一律写作转义序列(反斜杠 + u0009 那种写法),并在越门之外自扫 [\x00-\x08\x0b\x0c\x0e-\x1f](stack 渲染器读一个从未声明的 spacing 键,catalog 里 135 个节点照着它写 #4890 形态)。本 PR 正文自身也栽过一次:初版这一行里我把制表符写成了真实字节,读回存档时发现并改掉 —— 正是「写规则时把字节物化」的那个形状。

反向验证(先书面预判,再变异实测)

预判写在两个测试文件的 docblock 里,commit 后变异、git checkout -- 还原(未用 stash):

变异预判实测
门退回只验非空页面 11 红(七族 8 + 空值 + aria-invalid + 清除),且每例先死在「没有 alert 渲染」;submitRedirect.test.ts 全绿(模块未动)完全一致:Tests 11 failed | 58 passed,首个失败即 Unable to find role="alert"
保留解析、改用本地文案只有文案来源那一条断言红 9 例(七族 8 + 空值),saveItem 那半仍绿;aria / 清除 / 合法值 / thank-you 全绿完全一致:Tests 9 failed | 60 passed,失败点在 :158 toHaveTextContent

第二个变异是本卡真正要防的那件事的窄探测器:一份镜像拷贝会让 saveItem 那半照旧通过,只在文案来源上失守 —— 所以断言比的是 schema 现场解析出来的那句话,而不是我抄进测试的字符串;spec 改词,测试照绿,拷贝规则,测试变红。

objectstack#7496(2026-08-11 裁定,objectstack#7657 落 spec,本仓 17.0.0 GA pin
已覆盖)把 `submitBehavior.url` 裁为仅相对路径,spec 对七族越契约值各给一句
author-facing 处方。PublicFormsPage 的 redirect 分支只执行了其中第一族(非空),
其余六族原样写进 view 元数据 —— 管理员输入 `https://example.com/thanks` 或
`javascript:alert(1)` 得不到任何提示,而这个界面自己刚教过他这个值可以用:字段是
`type="url"`(它自己认的「合法」恰是本键拒绝的绝对 URL),占位符是
`https://example.com/thanks`。
- 保存时刻拒绝,文案来自 spec。判定复用 #4992 落地的同一条 `FormViewSchema`
解析路径:`submitRedirect` 新导出 `checkSubmitRedirectUrl`,消费侧
`resolveSubmitRedirect` 与授权门共用它。绝对 URL、script/data scheme、
协议相对 `//host`、反斜杠、空白与控制字符、畸形 `{{record.field_name}}` token、
文档相对路径、空值各自得到 spec 自己那句处方,显示在字段旁。
规则不在此处重写一份:镜像拷贝在 spec 改动前的每次取值比较里都是绿的,正是
`scripts/check-spec-symbol-derivation.mjs` 要劝退的形状;裁定日后放宽,跟着
pin 走而不需要改这里。
- 本地的 `Redirect URL is required` 去掉。空值本就是七族之一,一并走契约,作者
读到的是「目的地长什么样」而不是「必填」。
- 字段不再教错值:改回纯文本输入,占位符 `/thanks`,并给出规则提示(应用内路径、
`{{record.field_name}}` 插值、故意外跳请用 app navigation item)。
保存的是 schema 接受后回读的值,所以授权门与渲染侧不可能对同一个目的地有两种
意见。`thank-you` 的 `title`/`message` 刻意不校验:spec 里两者就是自由串,没有
可供门陈述的契约。
服务端已测(只读判定,未起服务):`saveMetaItem` 的 spec-conformance gate 走
`getMetadataTypeSchema('view')`(同一个 17.0.0 GA pin),对这些 body 返回
`422 invalid_metadata`,issue 落在 `submitBehavior.url`。所以本卡收口的是错误
路径而非静默入库:纠正现在出现在管理员能改的字段里,而不是一次失败的往返。
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-D06VKeDf.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)498.86KB111.27KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.03KB44.08KB
fields (index.js)233.27KB58.22KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.85KB32.73KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)240.05KB60.05KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)120.42KB29.03KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.61KB53.03KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.22KB26.98KB
plugin-map (index.js)18.72KB6.09KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.97KB11.33KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.53KB9.41KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.28KB0.68KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 验收 ✅ ACCEPT(#4990,批次 20)

实物核验(merge-base 85fdb0612,已核 a34c0b299/#4992 为其祖先):5 files,+471/−42 —— 修法按卡面「复用不复述」:checkSubmitRedirectUrl#4992FormViewSchema 解析里抽出(全程恰好一份解析),授权门保存时刻拒绝并把 spec 自己的处方文案渲染在字段旁(role=alert + aria 三件);顺手修掉了字段主动教错值的形状(type="url" + 绝对地址 placeholder → 纯文本 + /thanks + 规则提示);thank-you 自由文本边界按卡面不验并加钉防后人误扩。模型标识 msg/diff 双 0;releases/ 零触碰;changeset 按 #4190 console-only 先例 patch。

CI 亲读:20/20 check runs completed,零失败(两项 path-filter skipped 计绿)。

卡面开放点的实测回答(加分项):服务端确实拒绝该保存 —— 只读追到 saveMetaItem 的 spec-conformance 门,对门口构造的同一批值跑同一 schema,七族各 422 invalid_metadata 落在 submitBehavior.url 路径。卡面「either way」落到第一支:本卡修的是糟糕的错误路径而非静默入库 —— 修复因此更强(纠正落在管理员能改的字段上,门与服务端因同一 schema 而构造性一致)。未测的边界(活 HTTP 往返、非 GA pin 部署)如实写明。

反向验证读数:两向精确命中;变异 2(拒绝换本地文案)是承重探测器 —— 手写镜像会让拦保存半边继续绿、只红在文案出处,这正是断言对活 schema 解析比对而非抄字符串的理由。控制字节纪律两次自伤自纠(写控制字符测试时物化真实 tab),并点名 check-control-bytes 的 0x09 盲区 —— 只有越门自扫抓到,记账有价值。一条刻意不立的观察(viewKind 分支形状需真实存量数据佐证,立卡即种前提)记在报告与 PR 内 —— 不为立卡而立卡,处置正确。#4989 撞车由查重拦下未重复立卡。

undraft + auto-merge(squash)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 15:05
@yinlianghui
yinlianghui added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit ab9c970Aug 17, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4990-redirect-authoring-door branch August 17, 2026 15:06
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude