-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(hooks): close four trust-boundary holes in hook execution #8396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c018d25
cb6ec7c
38754ef
6d61cf6
aed434a
5813462
50b7f86
1353d0b
e3accbe
9569dbf
832f82b
e83f5e1
199aaee
0f9e98e
fb7b426
6c44667
b10ec55
935ae7e
502a8aa
846ec2d
edb11de
817e1dc
90002cb
f36390e
eedd924
dce013c
3ef677f
d4a76b9
ccf1a7a
ad00d83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -230,11 +230,28 @@ async function main() { | |
| const coreSrcUrl = pathToFileURL( | ||
| path.join(repoRoot, 'packages', 'core', 'index.ts'), | ||
| ).href; | ||
| // Core's package.json maps the ./envVarResolver subpath to dist, which a | ||
| // fresh checkout lacks (and a built one may keep stale). `after` mode | ||
| // pulls it in via SkillReviewDialog -> config/settings.js, so it needs | ||
| // the same source remap as the bare specifier (mirrors scripts/dev.js). | ||
| const envVarResolverSrcUrl = pathToFileURL( | ||
| path.join( | ||
| repoRoot, | ||
| 'packages', | ||
| 'core', | ||
| 'src', | ||
| 'utils', | ||
| 'envVarResolver.ts', | ||
| ), | ||
| ).href; | ||
| const loader = ` | ||
| export function resolve(specifier, context, nextResolve) { | ||
| if (specifier === '@qwen-code/qwen-code-core') { | ||
| return { shortCircuit: true, url: '${coreSrcUrl}', format: 'module' }; | ||
| } | ||
| if (specifier === '@qwen-code/qwen-code-core/envVarResolver') { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R13-2 (still standing from round 13; re-detected independently this round by the build-test efficacy probe, harness validated): the Suggested fix: confirm the terminal-capture harness run for this PR actually executed this path in CI, or exercise the loader remap in a collected test. 中文说明R13-2(第 13 轮遗留,本轮仍然成立;本轮 build-test 有效性探针独立再次发现,harness 已验证):本文件新增的 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||
| return { shortCircuit: true, url: '${envVarResolverSrcUrl}', format: 'module' }; | ||
| } | ||
|
Comment on lines
+252
to
+254
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R13-2: The 17 lines added to this file (the 中文说明[Suggestion] R13-2:本文件新增的 17 行(内嵌 ESM loader 中的 — qwen3.8-max via Qwen Code /review (v0.21.6) |
||
| return nextResolve(specifier, context); | ||
| } | ||
| `; | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -805,3 +805,84 @@ describe('parseChannelConfig', () => { | |||||||||||
| ); | ||||||||||||
| }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| describe('internal-secret denylist', () => { | ||||||||||||
| const SECRET = 'QWEN_SERVER_TOKEN'; | ||||||||||||
|
|
||||||||||||
| afterEach(() => { | ||||||||||||
| delete process.env[SECRET]; | ||||||||||||
| delete process.env[SECRET.toLowerCase()]; | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| it('resolveEnvVars rejects Qwen-internal secrets instead of yielding the name as a value', () => { | ||||||||||||
| process.env[SECRET] = 'daemon-secret'; | ||||||||||||
| expect(() => resolveEnvVars(`$${SECRET}`)).toThrow( | ||||||||||||
| `${SECRET} is a Qwen-internal secret`, | ||||||||||||
| ); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| it('resolveEnvVars rejects case variants (Windows-style process.env)', () => { | ||||||||||||
| process.env[SECRET.toLowerCase()] = 'daemon-secret'; | ||||||||||||
| expect(() => resolveEnvVars(`$${SECRET.toLowerCase()}`)).toThrow( | ||||||||||||
| 'is a Qwen-internal secret', | ||||||||||||
| ); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| it('parseChannelConfig rejects internal secrets in channel credentials', async () => { | ||||||||||||
| process.env[SECRET] = 'daemon-secret'; | ||||||||||||
|
|
||||||||||||
| await expect( | ||||||||||||
| parseChannelConfig('bot', { | ||||||||||||
| type: 'github', | ||||||||||||
| token: `$${SECRET}`, | ||||||||||||
| }), | ||||||||||||
| ).rejects.toThrow(`${SECRET} is a Qwen-internal secret`); | ||||||||||||
|
|
||||||||||||
| await expect( | ||||||||||||
| parseChannelConfig( | ||||||||||||
| 'bot', | ||||||||||||
| { type: 'github', token: `$${SECRET}` }, | ||||||||||||
| undefined, | ||||||||||||
| { resolveEnvVars: 'available' }, | ||||||||||||
| ), | ||||||||||||
| ).rejects.toThrow(`${SECRET} is a Qwen-internal secret`); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| it('webhook secret and secretEnv reject Qwen-internal secrets', async () => { | ||||||||||||
| process.env[SECRET] = 'daemon-secret'; | ||||||||||||
|
|
||||||||||||
| await expect( | ||||||||||||
| parseChannelConfig('dingtalk-main', { | ||||||||||||
| type: 'bare', | ||||||||||||
| token: 'token', | ||||||||||||
| webhooks: { | ||||||||||||
| sources: { | ||||||||||||
| custom: { | ||||||||||||
| secret: `$${SECRET}`, | ||||||||||||
| targets: { default: { chatId: 'group-1', senderId: 'webhook' } }, | ||||||||||||
| }, | ||||||||||||
| }, | ||||||||||||
| }, | ||||||||||||
| }), | ||||||||||||
| ).rejects.toThrow(`${SECRET} is a Qwen-internal secret`); | ||||||||||||
|
|
||||||||||||
| // secretEnv used to return the variable *name* as the secret — a | ||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R13-5 (still standing from round 13): this rationale comment misdescribes the pre-fix behavior. Verified against the merge base: Suggested fix: reword to describe the actual pre-fix behavior (resolved the variable's value into channel config). 中文说明R13-5(第 13 轮遗留,本轮仍然成立):该原理性注释对修复前行为的描述有误。已对合并基核实: — qwen3.8-max via Qwen Code /review (v0.21.6) |
||||||||||||
| // public constant that makes HMAC verification bypassable. | ||||||||||||
|
Comment on lines
+869
to
+870
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R13-5: This rationale comment misdescribes the pre-fix behavior. Verified against the merge base:
Suggested change
中文说明[Suggestion] R13-5:该理由注释误述了修复前的行为。已对合并基核实: — qwen3.8-max via Qwen Code /review (v0.21.6) |
||||||||||||
| await expect( | ||||||||||||
| parseChannelConfig('dingtalk-main', { | ||||||||||||
| type: 'bare', | ||||||||||||
| token: 'token', | ||||||||||||
| webhooks: { | ||||||||||||
| sources: { | ||||||||||||
| custom: { | ||||||||||||
| secretEnv: SECRET, | ||||||||||||
| targets: { default: { chatId: 'group-1', senderId: 'webhook' } }, | ||||||||||||
| }, | ||||||||||||
| }, | ||||||||||||
| }, | ||||||||||||
| }), | ||||||||||||
| ).rejects.toThrow( | ||||||||||||
| `references a Qwen-internal secret (${SECRET}); internal secrets are never resolved into channel config`, | ||||||||||||
| ); | ||||||||||||
| }); | ||||||||||||
| }); | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import type { | |
| ChannelWebhookSourceConfig, | ||
| ChannelWebhookTargetConfig, | ||
| } from '@qwen-code/channel-base'; | ||
| import { isInternalSecretEnvVar } from '@qwen-code/qwen-code-core/envVarResolver'; | ||
| import { resolveChannelCwd } from './channel-cwd.js'; | ||
| import { getPlugin, supportedTypes } from './channel-registry.js'; | ||
|
|
||
|
|
@@ -29,6 +30,16 @@ export function resolveEnvVars( | |
| } | ||
| if (value.startsWith('$')) { | ||
| const envName = value.substring(1); | ||
| // Qwen-internal secrets never resolve into channel config: resolved | ||
| // values are sent to repo-configured endpoints. The core resolver | ||
| // preserves placeholders, but channel config throws at config time | ||
| // like every other unresolvable reference — silently keeping the | ||
| // literal would turn a secret *name* into a public, guessable value. | ||
| if (isInternalSecretEnvVar(envName)) { | ||
|
Comment on lines
+37
to
+38
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R17-7: No test pins the Witness (probe this round): // in the new internal-secret denylist describe:
expect(resolveEnvVars('$$QWEN_SERVER_TOKEN')).toBe('$QWEN_SERVER_TOKEN');
// plus the parseChannelConfig equivalent through a credential field中文说明[Suggestion] R17-7:没有测试钉住 见证(本轮探针):HEAD 上 修复:在新的 internal-secret denylist describe 中增加 — qwen3.8-max via Qwen Code /review (v0.21.12) |
||
| throw new Error( | ||
| `Environment variable ${envName} is a Qwen-internal secret; internal secrets are never resolved into channel config (referenced as ${value})`, | ||
| ); | ||
| } | ||
| const envValue = env[envName]; | ||
| if (envValue === undefined) { | ||
| throw new Error( | ||
|
|
@@ -76,6 +87,11 @@ function resolveConfigEnvVar(value: string, mode: EnvResolution): string { | |
| if (value.startsWith('$$')) return value.substring(1); | ||
| if (mode === 'available' && value.startsWith('$')) { | ||
| const envName = value.substring(1); | ||
| if (isInternalSecretEnvVar(envName)) { | ||
| throw new Error( | ||
| `Environment variable ${envName} is a Qwen-internal secret; internal secrets are never resolved into channel config (referenced as ${value})`, | ||
| ); | ||
| } | ||
| const envValue = process.env[envName]; | ||
| if (envValue === undefined) { | ||
| throw new Error( | ||
|
|
@@ -290,6 +306,11 @@ function resolveWebhookSecretEnv( | |
| `Channel "${channelName}" field "${path}.secretEnv" must be an environment variable name or $-prefixed reference.`, | ||
| ); | ||
| } | ||
| if (isInternalSecretEnvVar(envName)) { | ||
| throw new Error( | ||
| `Channel "${channelName}" field "${path}.secretEnv" references a Qwen-internal secret (${envName}); internal secrets are never resolved into channel config.`, | ||
| ); | ||
| } | ||
| const envValue = env[envName]; | ||
| if (envValue === undefined) { | ||
| throw new Error( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2328,10 +2328,27 @@ export async function loadCliConfig( | |
| warnings: resolvedCliConfig.warnings, | ||
| bareMode, | ||
| safeMode, | ||
| allowedHttpHookUrls: | ||
| bareMode || safeMode | ||
| ? [] | ||
| : (settings.security?.allowedHttpHookUrls ?? []), | ||
| allowedHttpHookUrls: (() => { | ||
| if (bareMode || safeMode) { | ||
| return []; | ||
| } | ||
| // Settings files are validated only as top-level JSON objects, so a | ||
| // hand-edited file can put a bare string or non-string entries here; | ||
| // reduce it to a valid list before it reaches the UrlValidator, which | ||
| // maps over it and would abort startup on a non-array. | ||
| const hookUrls = settings.security?.allowedHttpHookUrls; | ||
| if (hookUrls !== undefined && !Array.isArray(hookUrls)) { | ||
| // The coercion to [] reads as "allow all" in the UrlValidator, so | ||
| // surface the lost restriction instead of silently starting | ||
| // unrestricted. | ||
| resolvedCliConfig.warnings.push( | ||
| 'Warning: security.allowedHttpHookUrls is not a list and was ignored; HTTP hooks are unrestricted apart from SSRF protection.', | ||
| ); | ||
| } | ||
| return Array.isArray(hookUrls) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R15-3: An array whose entries are ALL non-strings is silently coerced to Failure scenario: probe at this commit — settings const filtered = Array.isArray(hookUrls)
? hookUrls.filter((entry): entry is string => typeof entry === 'string')
: [];
if (Array.isArray(hookUrls) && hookUrls.length > 0 && filtered.length === 0) {
resolvedCliConfig.warnings.push(
'Warning: security.allowedHttpHookUrls contains no string entries and was ignored; HTTP hooks are unrestricted apart from SSRF protection.',
);
}
return filtered;中文说明[Suggestion] 全部条目都不是字符串的数组会被静默强转为 失败场景:已在本 commit 探针验证——settings — qwen3.8-max via Qwen Code /review (v0.21.6) |
||
| ? hookUrls.filter((entry): entry is string => typeof entry === 'string') | ||
| : []; | ||
|
Comment on lines
+2339
to
+2350
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R12-2: a malformed non-array // when the value is defined but not a valid string list, warn via the
// existing warnings channel (mirroring getSettingsWarnings' style):
const hookUrls = settings.security?.allowedHttpHookUrls;
if (hookUrls !== undefined && !Array.isArray(hookUrls)) {
resolvedCliConfig.warnings.push(
'security.allowedHttpHookUrls is not a valid list and was ignored; HTTP hooks are unrestricted apart from SSRF protection',
);
}中文说明[Suggestion] user/system scope 设置文件中畸形的非数组 — qwen3.8-max via Qwen Code /review (v0.21.5) |
||
| })(), | ||
| allowPrivateNetworkHooks: | ||
| bareMode || safeMode | ||
| ? false | ||
|
Comment on lines
2352
to
2354
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R15-10: The malformed-value hardening added four lines above covers only Failure scenario: probe at this commit — Suggested fix: coerce on the next line — 中文说明[Suggestion] 上方四行新增的畸形值加固只覆盖了 失败场景:已在本 commit 探针验证—— 修复建议:在下一行强转—— — qwen3.8-max via Qwen Code /review (v0.21.6) |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] R15-2: The PR description says "Both keys are now stripped from workspace scope" (and Risk & Scope says workspaces "must move it to user settings"), but the shipped behavior — this docs line,
narrowWorkspaceHookSecurityOverridesin packages/cli/src/config/settings.ts, the schema descriptions, and the narrowing tests — is narrow-only intersection: a trusted workspace may still NARROW a higher-scope whitelist; only uncovered entries are dropped.Probe: user
['https://hooks.corp.com/*']+ workspace['https://hooks.corp.com/ci/*']→ effective['https://hooks.corp.com/ci/*'](the "should let a trusted workspace whitelist narrow the user whitelist" test passes at this commit).Concrete cost: a maintainer validating claim 2 reads "stripped" and concludes a repository can never influence the effective whitelist, while the shipped code lets a trusted repo change it by narrowing; conversely, a user following the migration note moves a deliberately narrowing project whitelist to user scope and loses the per-project restriction.
Suggested fix: update the PR body before merge — describe item 2 as narrow-only intersection (workspace entries may only narrow a higher-scope whitelist; uncovered entries dropped; workspace ignored entirely when no higher scope sets one; System precedence), and replace the blanket migration note.
中文说明
[Suggestion] PR 描述写的是 "Both keys are now stripped from workspace scope"(风险与范围一节还要求把工作区白名单"移到 user settings"),但实际实现——本行文档、packages/cli/src/config/settings.ts 的
narrowWorkspaceHookSecurityOverrides、schema 描述以及收窄测试——是“仅收窄”的交集语义:受信任的工作区仍可收窄更高作用域的白名单,只有未被覆盖的条目会被丢弃。探针:user
['https://hooks.corp.com/*']+ workspace['https://hooks.corp.com/ci/*']→ 生效['https://hooks.corp.com/ci/*']("should let a trusted workspace whitelist narrow the user whitelist" 测试在本 commit 通过)。具体代价:维护者验证第 2 项时读到 "stripped",会以为仓库永远无法影响生效白名单,而实际代码允许受信仓库通过收窄来改变它;反过来,用户照迁移说明把刻意收窄用的项目白名单移到 user 作用域,反而失去按项目粒度的限制。
修复建议:合并前更新 PR 正文——把第 2 项描述为仅收窄交集(workspace 条目只能收窄更高作用域白名单;未覆盖条目被丢弃;更高作用域未设置时 workspace 值被完全忽略;System 优先),并替换一刀切的迁移说明。
— qwen3.8-max via Qwen Code /review (v0.21.6)