mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(ui): revealing a stored secret displayed the redaction sentinel as an editable value (#123493)
config.get never sends plaintext secrets — stored values arrive as the __OPENCLAW_REDACTED__ sentinel. The reveal eye had no sentinel awareness: clicking it showed the literal sentinel in a now-editable input, and any edit broke the exact-match restore so the mangled text was written over the real credential with a green Saved. Sentinel values are now never revealable (input stays readonly) and the disabled eye explains why.
This commit is contained in:
committed by
GitHub
parent
fc87337c14
commit
06e23130cb
@@ -355,4 +355,35 @@ describe("config form scalar integrity", () => {
|
||||
expect(reset.disabled).toBe(true);
|
||||
expect(container.textContent).not.toContain("inherited");
|
||||
});
|
||||
|
||||
it("never reveals a server-redacted sentinel and keeps the input readonly", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderTextInput({
|
||||
schema: { type: "string" },
|
||||
value: "__OPENCLAW_REDACTED__",
|
||||
path: ["secret"],
|
||||
hints: { secret: { sensitive: true } },
|
||||
unsupported: new Set(),
|
||||
disabled: false,
|
||||
inputType: "text",
|
||||
// Even with reveal forced on, the sentinel is not the stored value;
|
||||
// showing it editable would let a stray edit overwrite the credential.
|
||||
revealSensitive: true,
|
||||
onPatch: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const input = expectElement(
|
||||
container.querySelector<HTMLInputElement>("input"),
|
||||
"sentinel secret input",
|
||||
);
|
||||
expect(input.value).not.toContain("__OPENCLAW_REDACTED__");
|
||||
expect(input.readOnly).toBe(true);
|
||||
const eye = container.querySelector<HTMLButtonElement>(".settings-secret__toggle");
|
||||
expect(eye?.disabled ?? true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,9 @@ import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { ConfigUiHints } from "../api/types.ts";
|
||||
import { icons } from "../components/icons.ts";
|
||||
import "../components/tooltip.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import "../components/tooltip.ts";
|
||||
import { REDACTED_SENTINEL } from "../lib/config-form-utils.ts";
|
||||
import { formatUnknownText } from "../lib/format.ts";
|
||||
import { isSupportedConfigValueValid } from "./config-form.constraints.ts";
|
||||
import type { ConfigSearchCriteria } from "./config-form.search.ts";
|
||||
@@ -64,6 +65,7 @@ type SensitiveRenderState = {
|
||||
isRedacted: boolean;
|
||||
isRevealed: boolean;
|
||||
canReveal: boolean;
|
||||
sentinelRedacted: boolean;
|
||||
};
|
||||
|
||||
export function isAnySchema(schema: JsonSchema): boolean {
|
||||
@@ -130,14 +132,20 @@ export function getSensitiveRenderState(params: {
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
}): SensitiveRenderState {
|
||||
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
|
||||
// The server never sends plaintext secrets: a stored secret arrives as the
|
||||
// redaction sentinel. Revealing it would display the sentinel as an editable
|
||||
// value; any edit then overwrites the real credential with mangled text.
|
||||
const sentinel = params.value === REDACTED_SENTINEL;
|
||||
const isRevealed =
|
||||
isSensitive &&
|
||||
!sentinel &&
|
||||
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
|
||||
return {
|
||||
isSensitive,
|
||||
isRedacted: isSensitive && !isRevealed,
|
||||
isRevealed,
|
||||
canReveal: isSensitive,
|
||||
canReveal: isSensitive && !sentinel,
|
||||
sentinelRedacted: sentinel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -155,7 +163,9 @@ export function renderSensitiveToggleButton(params: {
|
||||
? state.isRevealed
|
||||
? t("configForm.hideValue")
|
||||
: t("configForm.revealValue")
|
||||
: t("configForm.disableStreamToReveal");
|
||||
: state.sentinelRedacted
|
||||
? t("configForm.storedSecretNotRevealable")
|
||||
: t("configForm.disableStreamToReveal");
|
||||
return html`
|
||||
<openclaw-tooltip .content=${label}>
|
||||
<button
|
||||
|
||||
@@ -1244,6 +1244,8 @@ export const en: TranslationMap = {
|
||||
hideValue: "Hide value",
|
||||
revealValue: "Reveal value",
|
||||
disableStreamToReveal: "Disable stream mode to reveal value",
|
||||
storedSecretNotRevealable:
|
||||
"Stored secrets are never sent to the browser; enter a new value to replace it",
|
||||
unsupportedType: "Unsupported type: {type}. Use Raw mode.",
|
||||
structuredSecretRaw: "Structured value (SecretRef) - use Raw mode to edit",
|
||||
structuredSecretFile: "Structured value (SecretRef) - edit the config file directly",
|
||||
|
||||
@@ -9,7 +9,7 @@ export function serializeConfigForm(form: Record<string, unknown>): string {
|
||||
return `${JSON.stringify(form, null, 2).trimEnd()}\n`;
|
||||
}
|
||||
|
||||
const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
|
||||
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
|
||||
type SanitizeResult = { omitted: true } | { omitted: false; value: unknown };
|
||||
|
||||
const OMIT_VALUE: SanitizeResult = { omitted: true };
|
||||
|
||||
Reference in New Issue
Block a user