diff --git a/workers/admin-panel/web/src/lib/messages.ts b/workers/admin-panel/web/src/lib/messages.ts index 725f219..1397f60 100644 --- a/workers/admin-panel/web/src/lib/messages.ts +++ b/workers/admin-panel/web/src/lib/messages.ts @@ -533,12 +533,24 @@ export const t = { keyPlaintextHint: '现在就复制给调用方。它不会存进草稿、也不会进任何日志 —— 关掉这张卡就真的没了,只有上面那串哈希留下。', keyCopy: '复制 key 明文', - /** 备用路:拿不到 crypto(老浏览器、非安全上下文)时仍然有办法。 */ - keyManualHint: '也可以在终端里生成一对:openssl rand -base64 32 | tr -d "\\n" | sha256sum', keysPlaceholder: '9f86d081884c7d659a2feaa0c55ad015…(64 位 hex)', header: 'key 所在的请求头', headerHelp: '默认 authorization(取 Bearer 后面的值)。自定义头填头名,值就是 key 本身。', keysDanger: 'access.keys', + /** 生成卡里摘要那一栏:让明文与刚填进列表的哈希当场对得上号。 */ + keyDigest: '这把 key 的 SHA-256 摘要', + keyDigestCopy: '复制 key 摘要', + keyDigestHint: '已经自动填进上面的哈希列表。以后要认出哪把 key 是这把,就看这一串。', + /** 本地校验的报错;都以字段 label 开头,error-index 的 stripLabel 靠这个去前缀。 */ + teamError: + 'team 名:只能用小写字母、数字和连字符,连字符不能放开头或结尾 —— 拼错就取不到 JWKS,所有请求都会 503', + emailsError: (item: string) => + `邮箱白名单:“${item}” 不是合法的邮箱地址,写错了这个人会被挡在门外`, + keysError: (item: string) => + `API key 的 SHA-256 哈希:${item} 不是 64 位小写 hex。这里要的是摘要,不是 key 本身`, + // 字符集是 RFC 9110 的 tchar,含一个反引号,没法整个塞进模板串。 + headerError: (name: string) => + `key 所在的请求头:${name} 不是合法的头名,只能用字母、数字和 !#$%&'*+-.^_\`|~`, }, forwardAuth: { label: '委托鉴权', diff --git a/workers/admin-panel/web/src/lib/types.ts b/workers/admin-panel/web/src/lib/types.ts index 3307ce4..a4b5f82 100644 --- a/workers/admin-panel/web/src/lib/types.ts +++ b/workers/admin-panel/web/src/lib/types.ts @@ -247,8 +247,11 @@ export const NUMERIC_BOUNDS = { streamIdleTimeoutMs: { min: 0, max: 600_000, default: 60_000 }, retries: { min: 0, max: 100, default: 0 }, retryBackoffMs: { min: 0, max: 5_000, default: 100 }, - /** 委托鉴权子请求的时限;schema 上限 5000,默认 2000。 */ - authTimeoutMs: { min: 1, max: 5_000, default: 2_000 }, + /** + * 委托鉴权子请求的时限;schema 上限 5000,默认 2000。 + * min 为 0:0 = 不设限(schema 放行;见 b311db1,运行时会先把 0 拦下来)。 + */ + authTimeoutMs: { min: 0, max: 5_000, default: 2_000 }, } as const; /** @@ -295,6 +298,15 @@ export const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'O /** 路由 ID 的合法形状,与服务端 `routeIdFrom` 的正则一致。 */ export const ROUTE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +/** + * access 块凭据字段的合法形状。三份正则逐字符对齐 + * `packages/jouska/src/config.ts` 的 access schema —— 本地校验的意见必须与 + * 服务端一致,所以这里抄的是同一份字符类,不是自己另写一套。 + */ +export const ACCESS_TEAM_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; +export const ACCESS_KEY_DIGEST_PATTERN = /^[0-9a-f]{64}$/; +export const HEADER_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + /** 服务端 validate.ts 的输入上限。 */ export const LIMITS = { definitionBytes: 64 * 1024, diff --git a/workers/admin-panel/web/src/views/route-editor.test.tsx b/workers/admin-panel/web/src/views/route-editor.test.tsx index de2b870..45e0b25 100644 --- a/workers/admin-panel/web/src/views/route-editor.test.tsx +++ b/workers/admin-panel/web/src/views/route-editor.test.tsx @@ -931,3 +931,111 @@ describe('RouteEditor 保存后的去发布引导(重设计)', () => { expect(toast.success).toHaveBeenCalledWith('new-route 已存入草稿', { action: undefined }); }); }); + +/* + * 身份认证的四处修补(本轮审计的结论): + * + * 1. 超时填 0 得活着到草稿 —— NUMERIC_BOUNDS 的 min 曾是 1,文案却说「0 = 不设 + * 限」,输入框悄悄把 0 丢回默认。这里的断言盯的是落库数据,不是报错。 + * 2. 生成卡里明文旁边必须有摘要:多把 key 并存时,这对并排的值是唯一能把 + * 「哪串哈希属于哪把明文」说清楚的地方。jsdom 的 crypto.subtle 是全的 + * (digest 与 getRandomValues 都实测可用),生成路径可以直接走真的。 + * 3. 凭据格式在本地就要开口:正则与服务端 config.ts 逐字符对齐,所以坏值必须 + * 在保存之前被点出来 —— 保存按钮禁用,错误一条不少。 + */ +describe('RouteEditor 身份认证的修补', () => { + beforeEach(() => { + vi.spyOn(api, 'domains').mockResolvedValue(configured([])); + vi.spyOn(api, 'putRoute').mockResolvedValue(undefined); + vi.spyOn(toast, 'success').mockImplementation(() => 1); + vi.spyOn(toast, 'error').mockImplementation(() => 1); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwardAuth 超时填 0 会原样落进草稿,不再被换回默认', async () => { + const user = userEvent.setup(); + renderEditor(false, { + upstream: 'origin.example.com', + forwardAuth: { url: 'https://sso.example.com/check' }, + }); + await ensureOpen(user, '委托鉴权'); + + const timeout = screen.getByLabelText(/鉴权请求超时/); + await user.clear(timeout); + await user.type(timeout, '0'); + + expect(await saveDraft(user)).toMatchObject({ + forwardAuth: { url: 'https://sso.example.com/check', timeoutMs: 0 }, + }); + }); + + it('坏凭据四条错误当场点名,保存按钮禁用', async () => { + renderEditor(false, { + upstream: 'origin.example.com', + access: { + cloudflare: { team: 'My_Team', emails: ['bad-email'] }, + keys: ['nothex'], + header: 'not a token', + }, + }); + await ensureOpen(userEvent.setup(), '身份验证'); + + expect(screen.getByText(/拼错就取不到 JWKS/)).toBeInTheDocument(); + expect(screen.getByText(/“bad-email” 不是合法的邮箱地址/)).toBeInTheDocument(); + expect(screen.getByText(/nothex 不是 64 位小写 hex/)).toBeInTheDocument(); + expect(screen.getByText(/not a token 不是合法的头名/)).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: '保存到草稿' })).toBeDisabled(); + }); + + it('合法凭据不拦人,保存照常', async () => { + const user = userEvent.setup(); + renderEditor(false, { + upstream: 'origin.example.com', + access: { + cloudflare: { team: 'my-team', emails: ['alice@example.com'] }, + keys: ['a'.repeat(64)], + header: 'x-api-key', + }, + }); + await ensureOpen(user, '身份验证'); + + expect(screen.queryByText(/拼错就取不到 JWKS/)).not.toBeInTheDocument(); + expect(screen.queryByText(/不是合法的邮箱地址/)).not.toBeInTheDocument(); + expect(screen.queryByText(/不是 64 位小写 hex/)).not.toBeInTheDocument(); + expect(screen.queryByText(/不是合法的头名/)).not.toBeInTheDocument(); + + expect(await saveDraft(user)).toMatchObject({ + access: { + cloudflare: { team: 'my-team', emails: ['alice@example.com'] }, + keys: ['a'.repeat(64)], + header: 'x-api-key', + }, + }); + }); + + it('生成 key 后明文旁边有摘要,且摘要已进 keys 列表', async () => { + const user = userEvent.setup(); + renderEditor(false, { upstream: 'origin.example.com', access: {} }); + await ensureOpen(user, '身份验证'); + + await user.click(screen.getByRole('button', { name: '生成一把新 key' })); + + const plaintext = await screen.findByLabelText('key 明文(只显示这一次)'); + const digest = screen.getByLabelText('这把 key 的 SHA-256 摘要') as HTMLInputElement; + + // 32 字节 base64url 去 padding 是 43 字符。toHaveValue 只认字面值不吃正则, + // 所以先取值再匹配。 + const plain = (plaintext as HTMLInputElement).value; + expect(plain).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(digest.value).toMatch(/^[0-9a-f]{64}$/); + expect(digest.value).not.toBe(plain); + + expect(await saveDraft(user)).toMatchObject({ + access: { keys: [digest.value] }, + }); + }); +}); diff --git a/workers/admin-panel/web/src/views/route-editor/access-key.tsx b/workers/admin-panel/web/src/views/route-editor/access-key.tsx index 95aabf5..cb4eede 100644 --- a/workers/admin-panel/web/src/views/route-editor/access-key.tsx +++ b/workers/admin-panel/web/src/views/route-editor/access-key.tsx @@ -7,8 +7,10 @@ * left them holding two hex-looking strings with no way to tell which was which. * * Now the browser does it. The plaintext is shown exactly once, in a field you can - * copy from, and only the digest is written into the draft. Nothing else ever holds - * the plaintext: it lives in this component's state and dies when the card closes. + * copy from, with its digest right beneath it — with several keys in the list, + * this pairing is the only way to tell which digest belongs to which plaintext. + * Only the digest is written into the draft. Nothing else ever holds the + * plaintext: it lives in this component's state and dies when the card closes. */ import * as React from 'react'; import { CheckIcon, CopyIcon, KeyRoundIcon } from 'lucide-react'; @@ -46,28 +48,30 @@ export const AccessKeyGenerator = ({ readonly onDigest: (digest: string) => void; }) => { const [plaintext, setPlaintext] = React.useState(null); - const [copied, setCopied] = React.useState(false); + const [digest, setDigest] = React.useState(null); + const [copied, setCopied] = React.useState<'plain' | 'digest' | null>(null); const [busy, setBusy] = React.useState(false); const generate = async () => { setBusy(true); const key = generateKey(); try { - const digest = await sha256Hex(key); - onDigest(digest); + const nextDigest = await sha256Hex(key); + onDigest(nextDigest); setPlaintext(key); - setCopied(false); + setDigest(nextDigest); + setCopied(null); } finally { setBusy(false); } }; - const copy = async () => { - if (plaintext === null) { + const copy = async (which: 'plain' | 'digest') => { + if (plaintext === null || digest === null) { return; } - await navigator.clipboard.writeText(plaintext); - setCopied(true); + await navigator.clipboard.writeText(which === 'digest' ? digest : plaintext); + setCopied(which); }; return ( @@ -84,7 +88,7 @@ export const AccessKeyGenerator = ({ {t.fields.access.keyGenerate} - {plaintext !== null && ( + {plaintext !== null && digest !== null && ( /* 明文用 danger-surface 标出来:它是这一屏里唯一一件「关掉就再也拿不回来」 的东西,而 DESIGN.md 把这块底色定义为「需要亲手确认的区域」。 @@ -105,13 +109,38 @@ export const AccessKeyGenerator = ({ void copy()} + onClick={() => void copy('plain')} > - {copied ? : } + {copied === 'plain' ? : } {t.fields.access.keyPlaintextHint} + + {/* 摘要就摆在明文旁边:多把 key 并存时,只有这里能把明文和列表里 + 的哈希对上号 —— 离开这张卡,配对的证据就只剩这一段。 */} + + {t.fields.access.keyDigest} + + + event.currentTarget.select()} + /> + + void copy('digest')} + > + {copied === 'digest' ? : } + + + + {t.fields.access.keyDigestHint} )} diff --git a/workers/admin-panel/web/src/views/route-editor/constants.ts b/workers/admin-panel/web/src/views/route-editor/constants.ts index 3ac17cd..ab7d618 100644 --- a/workers/admin-panel/web/src/views/route-editor/constants.ts +++ b/workers/admin-panel/web/src/views/route-editor/constants.ts @@ -121,7 +121,18 @@ export type SectionKey = 'bodyRewrite' | 'cors' | 'ip' | 'access' | 'forwardAuth /** 本地校验的错误集:键是字段,值是直接展示的文案。 */ export type FieldErrors = Partial< - Record<'id' | 'upstream' | 'scheme' | 'matchConditions' | NumericKey, string> + Record< + | 'id' + | 'upstream' + | 'scheme' + | 'matchConditions' + | NumericKey + | 'accessKeys' + | 'accessTeam' + | 'accessEmails' + | 'accessHeader', + string + > >; /** diff --git a/workers/admin-panel/web/src/views/route-editor/errors.ts b/workers/admin-panel/web/src/views/route-editor/errors.ts index d89b726..a9155e3 100644 --- a/workers/admin-panel/web/src/views/route-editor/errors.ts +++ b/workers/admin-panel/web/src/views/route-editor/errors.ts @@ -9,7 +9,10 @@ import { ApiError, NetworkError } from '@/lib/api'; import { t } from '@/lib/messages'; import { + ACCESS_KEY_DIGEST_PATTERN, + ACCESS_TEAM_PATTERN, DANGEROUS_PATHS, + HEADER_TOKEN_PATTERN, LIMITS, NUMERIC_BOUNDS, RESERVED_REQUEST_HEADERS, @@ -19,6 +22,9 @@ import type { RouteDefinition } from '@/lib/types'; import { NUMERIC_FIELDS, NUMERIC_KEYS } from './constants'; import type { AdvancedItem, FieldErrors, GuardsItem } from './constants'; +/** 报错里回显条目时截短到这个长度 —— 定位够了,又不至于把消息撑爆。 */ +const brief = (value: string): string => (value.length > 20 ? `${value.slice(0, 20)}…` : value); + /** 把明显的错拦在一次网络往返之前;权威判定在服务端 /api/preview。 */ export const collectErrors = ( createMode: boolean, @@ -75,6 +81,34 @@ export const collectErrors = ( }`; } } + // access 块的凭据格式。team/keys/header 是与服务端一字不差的精确正则,本地 + // 给出的意见与线上一致;邮箱只拦一眼能看出来的错 —— 邮箱合法性存在 IDN、 + // 加号地址这类边缘,学 ip 规则的先例不装权威,判定归 /api/preview。 + const access = definition.access; + if (access !== undefined) { + const team = access.cloudflare?.team; + if (team !== undefined && !ACCESS_TEAM_PATTERN.test(team)) { + errors.accessTeam = t.fields.access.teamError; + } + const emails = access.cloudflare?.emails; + if (emails !== undefined) { + const bad = emails.find((email) => !/\S+@\S+\.\S+/.test(email)); + if (bad !== undefined) { + errors.accessEmails = t.fields.access.emailsError(bad); + } + } + const keys = access.keys; + if (keys !== undefined) { + const bad = keys.find((key) => !ACCESS_KEY_DIGEST_PATTERN.test(key)); + if (bad !== undefined) { + errors.accessKeys = t.fields.access.keysError(brief(bad)); + } + } + const header = access.header; + if (header !== undefined && !HEADER_TOKEN_PATTERN.test(header)) { + errors.accessHeader = t.fields.access.headerError(header); + } + } return errors; }; @@ -193,4 +227,16 @@ export const ERROR_TARGETS: Record = { card: 'timing', label: NUMERIC_FIELDS.retryBackoffMs.label, }, + accessKeys: { fieldId: 'route-editor-access-keys', card: 'access', label: t.fields.access.keys }, + accessTeam: { fieldId: 'route-editor-access-team', card: 'access', label: t.fields.access.team }, + accessEmails: { + fieldId: 'route-editor-access-emails', + card: 'access', + label: t.fields.access.emails, + }, + accessHeader: { + fieldId: 'route-editor-access-header', + card: 'access', + label: t.fields.access.header, + }, }; diff --git a/workers/admin-panel/web/src/views/route-editor/fields.tsx b/workers/admin-panel/web/src/views/route-editor/fields.tsx index 53f4ccf..c50e26c 100644 --- a/workers/admin-panel/web/src/views/route-editor/fields.tsx +++ b/workers/admin-panel/web/src/views/route-editor/fields.tsx @@ -232,6 +232,7 @@ export interface ListPropertyProps { readonly tip?: string; readonly placeholder?: string; readonly value: readonly string[] | undefined; + readonly error?: string; readonly onChange: (value: readonly string[] | undefined) => void; } @@ -246,6 +247,7 @@ export const ListProperty = ({ tip, placeholder, value, + error, onChange, }: ListPropertyProps) => { const signature = JSON.stringify(value ?? null); @@ -258,13 +260,14 @@ export const ListProperty = ({ } return ( - + { const next = event.target.value; setText(next); @@ -277,6 +280,7 @@ export const ListProperty = ({ + {hasText(error) && {error}} ); }; diff --git a/workers/admin-panel/web/src/views/route-editor/section-guards.tsx b/workers/admin-panel/web/src/views/route-editor/section-guards.tsx index dbcf312..e48a8fd 100644 --- a/workers/admin-panel/web/src/views/route-editor/section-guards.tsx +++ b/workers/admin-panel/web/src/views/route-editor/section-guards.tsx @@ -42,6 +42,7 @@ export const SectionGuards = ({ draft }: { readonly draft: RouteDraft }) => { setSectionOff, setSectionKey, setAccessCloudflareKey, + shownErrors, forwardAuthReservedRequest, forwardAuthReservedResponse, } = draft; @@ -282,6 +283,7 @@ export const SectionGuards = ({ draft }: { readonly draft: RouteDraft }) => { hint={t.fields.access.teamHelp} value={definition.access.cloudflare.team ?? ''} mono + error={shownErrors.accessTeam} onChange={(value) => setAccessCloudflareKey('team', value === '' ? undefined : value) } @@ -303,6 +305,7 @@ export const SectionGuards = ({ draft }: { readonly draft: RouteDraft }) => { hint={t.fields.access.emailsHelp} placeholder={t.fields.access.emailsPlaceholder} value={definition.access.cloudflare.emails} + error={shownErrors.accessEmails} onChange={(value) => setAccessCloudflareKey('emails', value)} /> @@ -314,6 +317,7 @@ export const SectionGuards = ({ draft }: { readonly draft: RouteDraft }) => { hint={t.fields.access.keysHelp} placeholder={t.fields.access.keysPlaceholder} value={definition.access.keys} + error={shownErrors.accessKeys} onChange={(value) => setSectionKey('access', 'keys', value)} /> { hint={t.fields.access.headerHelp} value={definition.access.header ?? ''} mono + error={shownErrors.accessHeader} onChange={(value) => setSectionKey('access', 'header', value === '' ? undefined : value) }