Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions workers/admin-panel/web/src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '委托鉴权',
Expand Down
16 changes: 14 additions & 2 deletions workers/admin-panel/web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions workers/admin-panel/web/src/views/route-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
});
});
});
55 changes: 42 additions & 13 deletions workers/admin-panel/web/src/views/route-editor/access-key.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,28 +48,30 @@ export const AccessKeyGenerator = ({
readonly onDigest: (digest: string) => void;
}) => {
const [plaintext, setPlaintext] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const [digest, setDigest] = React.useState<string | null>(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 (
Expand All @@ -84,7 +88,7 @@ export const AccessKeyGenerator = ({
{t.fields.access.keyGenerate}
</Button>

{plaintext !== null && (
{plaintext !== null && digest !== null && (
/*
明文用 danger-surface 标出来:它是这一屏里唯一一件「关掉就再也拿不回来」
的东西,而 DESIGN.md 把这块底色定义为「需要亲手确认的区域」。
Expand All @@ -105,13 +109,38 @@ export const AccessKeyGenerator = ({
<InputGroupButton
type="button"
aria-label={t.fields.access.keyCopy}
onClick={() => void copy()}
onClick={() => void copy('plain')}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied === 'plain' ? <CheckIcon /> : <CopyIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription>{t.fields.access.keyPlaintextHint}</FieldDescription>

{/* 摘要就摆在明文旁边:多把 key 并存时,只有这里能把明文和列表里
的哈希对上号 —— 离开这张卡,配对的证据就只剩这一段。 */}
<FieldLabel htmlFor="route-editor-access-key-digest">
{t.fields.access.keyDigest}
</FieldLabel>
<InputGroup>
<InputGroupInput
id="route-editor-access-key-digest"
readOnly
className="font-mono"
value={digest}
onFocus={(event) => event.currentTarget.select()}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
aria-label={t.fields.access.keyDigestCopy}
onClick={() => void copy('digest')}
>
{copied === 'digest' ? <CheckIcon /> : <CopyIcon />}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldDescription>{t.fields.access.keyDigestHint}</FieldDescription>
</Field>
)}
</>
Expand Down
13 changes: 12 additions & 1 deletion workers/admin-panel/web/src/views/route-editor/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
>;

/**
Expand Down
46 changes: 46 additions & 0 deletions workers/admin-panel/web/src/views/route-editor/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -193,4 +227,16 @@ export const ERROR_TARGETS: Record<keyof FieldErrors, ErrorTarget> = {
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,
},
};
Loading
Loading