LANG-145: hide passkey UI unless the core reports it is supported (#2754)

* fix(adapters): stop SSRF and arbitrary local file reads in media handling

The platform adapters fetch URLs and open paths that come from inbound
messages, so both are attacker-influenced.

New guard module pkg/utils/outbound_url.py:

- assert_safe_outbound_url: checks the scheme, rejects literal private /
  loopback / link-local / reserved / multicast addresses, and resolves the
  hostname so a public-looking name pointing at an internal address is refused
  too. Optional allowed_domains confines a fetch to a known prefix.
- assert_safe_local_media_path: resolves a caller-supplied path and requires it
  to stay inside the media roots (data dir + temp dir), so symlinks and .. cannot
  escape. Directories are refused.

Wired in:

- DingTalk send_message: session_webhook is taken from the inbound event and was
  POSTed verbatim, so a forged event could redirect reply content (including LLM
  output) to any host. Now restricted to dingtalk.com.
- DingTalk send_attachment: component.url was fetched with no destination check;
  now goes through the SSRF guard, which also covers cloud metadata.
- DingTalk upload_image_media: caller-supplied file_path was opened directly; now
  confined to the media roots.
- Slack attachment_bytes: component.path was opened directly and uploaded to the
  channel (arbitrary file exfiltration); now confined to the media roots.
- Slack download_media: redirects were followed with no destination check. Every
  hop is now validated, the hop count is bounded, and a redirect off the Slack
  domain drops the bearer token.

Tests: 25 new cases for the guard (internal address forms, scheme rejection,
suffix impostor, traversal, symlink escape, directory). The Slack credential test
now stubs DNS because the guard resolves before fetching, and asserts
allow_redirects is False for every case since redirects are handled manually.

Co-authored-by: multica-agent <[email protected]>

* fix(web): hide passkey UI unless the core reports it is supported

The core refuses every passkey route on a cloud deployment (403
edition_unsupported) and reports passkey_login_enabled/passkey_supported as
false. The WebUI still rendered the controls:

- Login page read 'passkey_login_enabled !== false || passkey_supported', so an
  absent flag counted as enabled. It now requires an explicit true.
- The account settings panel always rendered the passkey section and called
  GET /passkeys on open, which 403s on cloud. It now reads the capabilities
  first, skips the list call when unsupported, and does not render the section.

Also drop the client-supplied origin from both passkey options requests. The
server resolves the WebAuthn RP ID and expected origin from api.webui_url /
api.webhook_prefix, so sending window.location.origin was dead weight that would
invite a future handler to trust it again.

Verified: tsc --noEmit clean, eslint clean (only two pre-existing hook-dep
warnings), pnpm build succeeds.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-22 21:26:33 +08:00
committed by GitHub
co-authored by multica-agent
parent c759da50b1
commit a8dd85063f
3 changed files with 133 additions and 109 deletions
@@ -67,6 +67,9 @@ export default function AccountSettingsPanel({
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
// Passkeys are OSS-only. The core reports explicit false on cloud deployments,
// so an absent flag must not be read as "enabled".
const [passkeySupported, setPasskeySupported] = useState(false);
const [passkeyLoading, setPasskeyLoading] = useState(false);
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
@@ -90,7 +93,7 @@ export default function AccountSettingsPanel({
useEffect(() => {
if (active) {
loadUserInfo();
loadPasskeys();
loadPasskeyCapability();
}
}, [active]);
@@ -165,6 +168,22 @@ export default function AccountSettingsPanel({
}
}
async function loadPasskeyCapability() {
try {
const res = await httpClient.getAccountInfo();
const supported =
res.passkey_login_enabled === true || res.passkey_supported === true;
setPasskeySupported(supported);
if (supported) {
await loadPasskeys();
}
} catch {
// Treat an unreadable capability response as "not supported": the passkey
// routes refuse on cloud anyway, and this avoids a spurious error toast.
setPasskeySupported(false);
}
}
async function loadPasskeys() {
setPasskeyLoading(true);
try {
@@ -181,7 +200,7 @@ export default function AccountSettingsPanel({
setRegisteringPasskey(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyRegisterOptions(window.location.origin);
await httpClient.getPasskeyRegisterOptions();
const regResp = await startRegistration({ optionsJSON: options });
const defaultName =
prompt(t('account.passkeyNamePlaceholder')) || undefined;
@@ -320,104 +339,106 @@ export default function AccountSettingsPanel({
)}
</Item>
{/* Passkey Section */}
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.passkeySectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.passkeySectionDesc')}
</p>
{/* Passkey Section — OSS-only, so it is not rendered on cloud. */}
{passkeySupported && (
<div className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">
{t('account.passkeySectionTitle')}
</h4>
<p className="text-xs text-muted-foreground">
{t('account.passkeySectionDesc')}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddPasskey}
disabled={
registeringPasskey || !systemInfo.allow_modify_login_info
}
className="cursor-pointer"
>
{registeringPasskey ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('account.addPasskey')}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddPasskey}
disabled={
registeringPasskey || !systemInfo.allow_modify_login_info
}
className="cursor-pointer"
>
{registeringPasskey ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
{t('account.addPasskey')}
</Button>
</div>
{passkeyLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noPasskeys')}
</div>
) : (
<div className="space-y-2">
{passkeys.map((pk) => (
<Item
key={pk.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Fingerprint className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{pk.name}</ItemTitle>
<ItemDescription>
{pk.created_at && (
<span>
{t('account.passkeyCreated', {
date: new Date(
pk.created_at,
).toLocaleDateString(),
})}
</span>
)}
{pk.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.passkeyLastUsed', {
date: new Date(
pk.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
disabled={!systemInfo.allow_modify_login_info}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
onClick={() => handleDeletePasskey(pk.uuid)}
disabled={!systemInfo.allow_modify_login_info}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</ItemActions>
</Item>
))}
</div>
)}
</div>
{passkeyLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : passkeys.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
{t('account.noPasskeys')}
</div>
) : (
<div className="space-y-2">
{passkeys.map((pk) => (
<Item
key={pk.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Fingerprint className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{pk.name}</ItemTitle>
<ItemDescription>
{pk.created_at && (
<span>
{t('account.passkeyCreated', {
date: new Date(
pk.created_at,
).toLocaleDateString(),
})}
</span>
)}
{pk.last_used_at && (
<span className="ml-2">
·{' '}
{t('account.passkeyLastUsed', {
date: new Date(
pk.last_used_at,
).toLocaleDateString(),
})}
</span>
)}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer"
onClick={() => handleRenamePasskey(pk.uuid, pk.name)}
disabled={!systemInfo.allow_modify_login_info}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive cursor-pointer hover:text-destructive"
onClick={() => handleDeletePasskey(pk.uuid)}
disabled={!systemInfo.allow_modify_login_info}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</ItemActions>
</Item>
))}
</div>
)}
</div>
)}
{/* TOTP second factor. The card is informational: changing the factor
happens from the action on the Account's own row. */}
+10 -6
View File
@@ -1760,11 +1760,13 @@ export class BackendClient extends BaseHttpClient {
// ============ Passkey (WebAuthn) API ============
public getPasskeyAuthOptions(
email?: string,
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
// No origin is sent: the WebAuthn RP ID and expected origin are resolved
// server-side from api.webui_url / api.webhook_prefix. Sending a client
// origin would only invite a future handler to trust it again.
return this.post(
'/api/v1/user/passkey/auth/options',
{ email, origin },
{ email },
{ skipWorkspace: true },
);
}
@@ -1780,12 +1782,14 @@ export class BackendClient extends BaseHttpClient {
);
}
public getPasskeyRegisterOptions(
origin?: string,
): Promise<{ options: any; challenge_token: string }> {
public getPasskeyRegisterOptions(): Promise<{
options: any;
challenge_token: string;
}> {
// See getPasskeyAuthOptions: the origin is server-configured, not supplied.
return this.post(
'/api/v1/user/passkey/register/options',
{ origin },
{},
{ skipWorkspace: true },
);
}
+4 -5
View File
@@ -105,8 +105,10 @@ export default function Login() {
}
setShowLocalLogin(res.password_login_enabled !== false);
setShowSpaceLogin(res.space_login_enabled !== false);
// Passkeys are OSS-only: the core reports explicit false on cloud, so an
// absent flag must not be read as "enabled".
setShowPasskeyLogin(
res.passkey_login_enabled !== false || Boolean(res.passkey_supported),
res.passkey_login_enabled === true || res.passkey_supported === true,
);
setLoading(false);
@@ -206,10 +208,7 @@ export default function Login() {
setPasskeyLoading(true);
try {
const { options, challenge_token } =
await httpClient.getPasskeyAuthOptions(
undefined,
window.location.origin,
);
await httpClient.getPasskeyAuthOptions(undefined);
const authResp = await startAuthentication({ optionsJSON: options });
const res = await httpClient.verifyPasskeyAuth(challenge_token, authResp);
// A rejected assertion answers with an error payload and no session, so a