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
2 changes: 2 additions & 0 deletions applets/weblite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Core 连接令牌 → Tinker 领取并消费令牌

Tinker 原样传入 `asset`、`account`、`platform` 等通用连接数据,不识别 Applet 名称、不组装 Web 专属参数。WebLite 优先使用 `asset.spec_info` 的登录配置,未设置代填模式时回退到对应平台协议配置;安全模式来自平台配置,匿名账号 `@ANON` 关闭代填。数据通过管道传入,不进入命令行或环境变量。主进程读取并校验启动管道,渲染进程只获得显示配置。凭据只允许在资产或脚本明确指定的 origin 领取一次,关闭、失败或完成登录时释放引用。每次启动使用独立临时 profile 和内存网站 session,可并发运行。

基础代填的用户名、密码、提交、成功和交互区域选择器均可独立留空。未配置提交选择器时仅代填,保留页面输入并交还操作,用户可手动登录;此时不进入受保护的交互区域流程、不等待成功标记,也不会显示为已提交或登录成功。配置了提交选择器时仍按已配置的交互区域和成功标记执行自动登录。非空选择器仍需格式正确,已配置的登录元素仍需等待页面加载并通过校验。

Website 资产的 `allowed_urls` 为可选访问白名单:留空允许所有站点;配置后只允许导航到资产地址所属站点和列表中的 HTTP/HTTPS origin(协议、主机、端口,不含路径或通配符)。同一策略用于页面链接、重定向、弹窗和登录脚本中的 `open`;子页面和图片、脚本等资源不受此导航策略限制。Tinker 通过启动管道传入该字段,桌面客户端通过资产详情传入,共用浏览器导航校验。

HTTPS 资产支持自签名证书和私有 CA。仅在当前网站的独立 session 中,对资产地址、访问白名单和登录脚本 origin 明确指定的 HTTPS 主机放行 `ERR_CERT_AUTHORITY_INVALID`;独立浏览模式也支持地址栏手动输入的 HTTPS 主机。其他主机及其他证书错误继续使用 Chromium 校验。信任按主机匹配,不区分端口,不随页面重定向自动扩大,也不修改系统证书库。自签名的 SSO 中转站或资源主机需显式配置,或在发布机安装可信 CA。
Expand Down
36 changes: 35 additions & 1 deletion applets/weblite/tests/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ const applet = {
account: { username: "tester", secret: "one-use-secret", secret_type: { value: "password" } }
};

test("basic autofill accepts independently omitted selectors in both launch formats", () => {
const fields = ["username", "password", "submit", "success", "interactive"];
for (const missing of [undefined, null, ""]) {
for (const field of [...fields, "all"]) {
const config = { ...launch.login.config };
for (const name of field === "all" ? fields : [field]) config[`${name}_selector`] = missing;
for (const data of [
{ ...launch, login: { ...launch.login, config } },
{ ...applet, asset: { ...applet.asset, spec_info: config } }
]) {
const session = parseLaunch(data).localSession;
assert.equal(session.autofillAvailable, true);
for (const name of fields)
assert.equal(session.selectors[name], name === field || field === "all" ? "" : config[`${name}_selector`]);
}
}
}
});

test("optional selectors still reject malformed nonempty configuration", () => {
for (const field of ["username", "password", "submit", "success", "interactive"]) {
for (const value of [false, 0, {}, " ", "id=", "javascript=alert(1)"]) {
assert.throws(
() =>
parseLaunch({
...launch,
login: { ...launch.login, config: { ...launch.login.config, [`${field}_selector`]: value } }
}),
/代填元素配置无效/
);
}
}
});

test("generic AppletArgs opens the asset and fills credentials without an app-name dependency", async () => {
const result = await readLaunch(Readable.from([JSON.stringify(applet)]));
assert.equal(result.targetUrl, launch.target_url);
Expand Down Expand Up @@ -254,7 +288,7 @@ test("rejects invalid configuration and oversized pipes", async () => {
{ safe_mode: "false" },
{ recording_enabled: true, login: undefined },
{ login: { config: { autofill: "invalid" } } },
{ login: { config: { autofill: "basic" }, password: "secret" } },
{ login: { config: { autofill: "basic", submit_selector: "invalid" }, password: "secret" } },
{ login: { config: { autofill: "script", script: [{ step: 1, command: "select_frame", target: "id=login" }] } } }
])
assert.throws(() => parseLaunch({ ...launch, ...patch }));
Expand Down
58 changes: 58 additions & 0 deletions electron/tests/web-proxy-interaction-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,69 @@
</script></body></html>`;

export async function run() {
await runOptionalSelectorChecks();
for (const success of ["id=success", ""]) {
for (const challenge of ["initial", "after-failed", "none"]) await runCase(success, challenge);
}
}

async function runOptionalSelectorChecks() {

Check failure on line 50 in electron/tests/web-proxy-interaction-check.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 23 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=jumpserver_luna&issues=AaDHLLo1ekRmU3X88w7v&open=AaDHLLo1ekRmU3X88w7v&pullRequest=1725
const host = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } });
const evaluate = (code: string) => host.webContents.executeJavaScript(code, true);
try {
for (let mask = 0; mask < 12; mask++) {
const selectors = {
username: mask & 1 ? "id=username" : "",
password: mask & 2 ? "id=password" : "",
submit: mask & 4 ? "id=submit" : "",
interactive: mask & 8 ? "id=mfa" : "",
success: mask & 8 ? "id=success" : ""
};
const html = `<!doctype html><form>
${selectors.username ? '<input id="username">' : ""}
${selectors.password ? '<input id="password" type="password">' : ""}
<input id="untouched" value="user-entered"><button id="${selectors.submit ? "submit" : "manual"}">Login</button>
</form><section id="mfa">Verification</section><script>
globalThis.submissions = [];
document.querySelector('form').onsubmit = event => {
event.preventDefault();
submissions.push({ username: document.querySelector('#username')?.value || '', password: document.querySelector('#password')?.value || '' });
};</script>`;
await host.loadURL(`data:text/html,${encodeURIComponent(html)}`);
assert.equal(await evaluate(buildAutofillProbeScript(selectors)), true, `optional selectors, mask ${mask}`);
assert.equal(
await evaluate(buildAutofillScript(selectors, { username: "managed-user", password: "managed-secret" })),
true
);
assert.equal(await evaluate("document.querySelector('#untouched').value"), "user-entered");
assert.equal(await evaluate("document.querySelector('[data-jms-secure-login]') === null"), true);
if (!selectors.submit) {
assert.equal(await evaluate("submissions.length"), 0, "fill-only must not submit implicitly");
await evaluate("document.querySelector('#manual').click()");
}
assert.deepEqual(await evaluate("submissions"), [
{
username: selectors.username ? "managed-user" : "",
password: selectors.password ? "managed-secret" : ""
}
]);
}
const guarded = (code: string) => host.webContents.executeJavaScriptInIsolatedWorld(INTERACTION_WORLD, [{ code }]);
await guarded(
buildInteractionGuardScript({ username: "id=username", submit: "id=submit", interactive: "id=missing" }, "null")
);
assert.equal(await guarded("globalThis.__jmsVerification.advanceLogin(false)"), "waiting");
await delay(600);
assert.equal(
await guarded("globalThis.__jmsVerification.advanceLogin(false)"),
"waiting",
"a username-only form must not be mistaken for a completed login"
);
} finally {
host.destroy();
}
}

async function runCase(successSelector: string, challenge: string) {
const server = createServer((_req, res) => {
res.setHeader("content-type", "text/html");
Expand Down
2 changes: 1 addition & 1 deletion electron/tests/web-proxy-script-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ async function loginCase(crossOrigin: boolean) {
{ steps: timeoutSteps, accessToken: "" },
{ active: () => true, state: () => {}, interaction: () => {}, frame: () => {} }
);
await assert.rejects(timeout.run(), /超时/);
await assert.rejects(timeout.run(), /WebProxy\.ScriptStepTimeout/);
const cancelled = new WebProxyScript(
view.webContents,
{ steps: timeoutSteps, accessToken: "" },
Expand Down
68 changes: 52 additions & 16 deletions electron/tests/web-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ async function setupAutofill() {
autofillProbeId: 0,
autofillFailure: "",
autofillVisibilityBlocked: true,
credentialSession: { origin: "https://example.test", selectors: { success: "" } },
credentialSession: { origin: "https://example.test", selectors: { success: "", submit: "id=submit" } },
recording: { setPaused: mock.fn() }
};
const release = mock.fn(async () => ({ username: "managed-user", password: "secret" }));
Expand Down Expand Up @@ -755,6 +755,40 @@ test("discards credentials arriving after timeout and keeps recording paused", a
assert.equal(managed.autofillVisibilityBlocked, true);
});

test("fill-only login releases the page without claiming submission or waiting for a success marker", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
for (const [success, interactive] of [
["", ""],
["id=success", ""],
["", "id=mfa"],
["id=success", "id=mfa"]
]) {
const { api, managed, events, release, webContents } = await setupAutofill();
managed.credentialSession.selectors = {
username: "id=username",
password: "id=password",
submit: "",
success,
interactive
};
const dispose = mock.fn();
managed.credentialSession.dispose = dispose;
api.startWebProxyAutofillWait(managed);
await api.tryWebProxyAutofill(managed);
assert.equal(managed.autofillPending, false);
assert.equal(managed.autofillVisibilityBlocked, false);
assert.equal(managed.view.setVisible.mock.calls.at(-1).arguments[0], true);
assert.equal(release.mock.callCount(), 1);
assert.equal(dispose.mock.callCount(), 1);
assert.equal(webContents.executeJavaScriptInIsolatedWorld.mock.callCount(), 0);
assert.ok(events.some(({ state }) => state.status === "filled"));
assert.ok(!events.some(({ state }) => state.status === "submitted" || state.status === "success"));
t.mock.timers.tick(60_000);
assert.equal(webContents.stop.mock.callCount(), 0);
assert.equal(managed.autofillFailure, "");
}
});

test("does not send a late preview to a closed session", async () => {
const { api, managed, webContents, events, image, views } = await setupAutofill();
const capture = deferred<typeof image>();
Expand Down Expand Up @@ -794,6 +828,7 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => {
let clientPublicKey: ReturnType<typeof createPublicKey> | undefined;
let requestCount = 0;
let proxyUrl = "";
let loginSelectors: Record<string, string | null> = {};
const server = createServer((request, response) => {
requestCount += 1;
const chunks = [];
Expand Down Expand Up @@ -822,9 +857,7 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => {
target_url: "https://example.com/login",
origin: "https://example.com",
autofill_available: true,
username_selector: "id=username",
password_selector: "id=password",
submit_selector: "id=submit",
...loginSelectors,
server_public_key: serverPublicKey.export({ type: "spki", format: "der" }).toString("base64")
})
);
Expand Down Expand Up @@ -853,13 +886,14 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => {
proxyUrl = `http://127.0.0.1:${port}`;

try {
for (const [successSelector, interactiveSelector] of [
["", ""],
["css=.dashboard", ""],
["", "id=mfa"],
["css=.dashboard", "id=mfa"]
]) {
for (const [successSelector, interactiveSelector, selectors] of [
["", "", { username_selector: "id=username", password_selector: "id=password", submit_selector: "id=submit" }],
["css=.dashboard", "", {}],
["", "id=mfa", { username_selector: null, password_selector: null, submit_selector: null }],
["css=.dashboard", "id=mfa", { username_selector: "", password_selector: "", submit_selector: "" }]
] as const) {
requestCount = 0;
loginSelectors = selectors;
const session = await createCredentialSession(
proxyUrl,
"https://example.com/login",
Expand All @@ -872,6 +906,8 @@ test("decrypts the Koko-compatible one-time credential envelope", async () => {
assert.equal(session.sessionId, "62a7496e-369d-4f3d-b3f9-a20b61a33980");
assert.equal(session.selectors.success, successSelector);
assert.equal(session.selectors.interactive, interactiveSelector);
for (const name of ["username", "password", "submit"])
assert.equal(session.selectors[name], loginSelectors[`${name}_selector`] ?? "");
const credentials = await releaseCredentials(session, "https://example.com/login");
assert.deepEqual(credentials, { username: "managed-user", password: "managed-password" });
assert.equal(session.accessToken, "");
Expand Down Expand Up @@ -975,7 +1011,7 @@ test("imports replay archives into scoped, decompressed offline entries", async
test("interactive verification replaces both automatic login deadlines without exposing the page", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { api, managed, webContents } = await setupAutofill();
managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa" };
managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa", submit: "id=submit" };
api.startWebProxyAutofillWait(managed);
await api.tryWebProxyAutofill(managed);
const interaction = managed.interaction;
Expand All @@ -997,7 +1033,7 @@ test("interactive verification replaces both automatic login deadlines without e

test("interactive verification is torn down before the final successful session is exposed", async () => {
const { api, managed, webContents } = await setupAutofill();
managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa" };
managed.credentialSession.selectors = { success: "id=success", interactive: "id=mfa", submit: "id=submit" };
await api.tryWebProxyAutofill(managed);
await Promise.resolve();
const interaction = managed.interaction;
Expand All @@ -1022,7 +1058,7 @@ test("interactive verification is torn down before the final successful session
for (const success of ["", "id=success"]) {
test(`optional verification allows a normal login without any verification frame (success selector: ${success || "empty"})`, async () => {
const { api, managed, events, webContents, release } = await setupAutofill();
managed.credentialSession.selectors = { success, interactive: "id=mfa" };
managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" };
await api.tryWebProxyAutofill(managed);
const interaction = managed.interaction;
// Drain the initial asynchronous page probe before simulating submission.
Expand All @@ -1044,7 +1080,7 @@ for (const success of ["", "id=success"]) {
test(`verification appearing after submission pauses the same login (success selector: ${success || "empty"})`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { api, managed, release } = await setupAutofill();
managed.credentialSession.selectors = { success, interactive: "id=mfa" };
managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" };
await api.tryWebProxyAutofill(managed);
await Promise.resolve();
const interaction = managed.interaction;
Expand All @@ -1063,7 +1099,7 @@ for (const success of ["", "id=success"]) {

test(`verification submits before continuing the session (success selector: ${success || "empty"})`, async () => {
const { api, managed, events, webContents } = await setupAutofill();
managed.credentialSession.selectors = { success, interactive: "id=mfa" };
managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" };
await api.tryWebProxyAutofill(managed);
const interaction = managed.interaction;
assert.equal(await api.completeWebProxyVerification(managed), false);
Expand Down Expand Up @@ -1111,7 +1147,7 @@ for (const success of ["", "id=success"]) {
test(`late manual completion does not unlock a timed-out page (success selector: ${success || "empty"})`, async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
const { api, managed } = await setupAutofill();
managed.credentialSession.selectors = { success, interactive: "id=mfa" };
managed.credentialSession.selectors = { success, interactive: "id=mfa", submit: "id=submit" };
await api.tryWebProxyAutofill(managed);
managed.verificationReady();
const cleared = deferred<boolean>();
Expand Down
4 changes: 3 additions & 1 deletion packages/web-proxy/src/WebProxySurface.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface WebProxyState {

interface WebProxyAutofillState {
label: string;
status: "ready" | "filling" | "submitted" | "interactive" | "success" | "unavailable" | "error";
status: "ready" | "filling" | "filled" | "submitted" | "interactive" | "success" | "unavailable" | "error";
message: string;
}

Expand Down Expand Up @@ -135,6 +135,8 @@ const autofillLabel = computed(() => {
return t("WebProxy.AwaitingAutofill");
case "filling":
return t("WebProxy.SecureLogin");
case "filled":
return t("WebProxy.AutofillCompleted");
case "interactive":
return t("WebProxy.AwaitingVerification");
case "submitted":
Expand Down
Loading
Loading