Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -793,13 +793,100 @@ public static void fillField(WebElement field, String value) {
field.sendKeys(value);
}

/**
* Fills a field with a value and fires input/change events to support any JavaScript
* framework (React, Angular, Vue, Svelte, Web Components, plain HTML with JS listeners).
*
* <p>This method performs the same field filling as {@link #fillField(WebElement, String)},
* but additionally fires 'input' and 'change' events via JavaScript. This ensures
* compatibility with frameworks that use synthetic or virtual event systems and may not
* respond to native browser events triggered by Selenium's sendKeys().
*
* <p>Events are fired with both {@code bubbles: true} and {@code composed: true} so they
* propagate correctly through Shadow DOM boundaries (Web Components).
*
* @param field the form field element
* @param value the value to fill into the field
* @param driver the WebDriver instance to execute JavaScript
*/
public static void fillFieldWithEvents(WebElement field, String value, WebDriver driver) {
// Fill the field using native sendKeys
if (StringUtils.isNotEmpty(getAttribute(field, "value"))) {
field.clear();
}
field.sendKeys(value);

// Fire input and change events for framework compatibility.
// bubbles:true — event travels up the DOM tree (React, Angular, Vue, jQuery)
// composed:true — event crosses Shadow DOM boundaries (Web Components)
try {
if (driver instanceof JavascriptExecutor je) {
je.executeScript(
"var element = arguments[0];"
+ "var event = new Event('input', { bubbles: true, composed: true });"
+ "element.dispatchEvent(event);"
+ "event = new Event('change', { bubbles: true, composed: true });"
+ "element.dispatchEvent(event);",
field);
}
} catch (Exception e) {
// Silently ignore JavaScript execution errors - field was still filled
LOGGER.debug("Failed to fire input/change events for field: {}", e.getMessage());
}
}

/**
* Fills a set of single-character OTP input boxes with consecutive characters from {@code
* code}.
*
* <p>When the target TOTP field accepts only a single character ({@code maxlength="1"}), the
* site uses individual input boxes instead of one combined field. This method uses JavaScript
* to locate all {@code input[maxlength="1"]} elements within the same form or parent container
* as {@code firstField}, then sends one character of {@code code} to each box in DOM order.
*
* @param wd the WebDriver instance.
* @param firstField the first OTP input box, used to locate the container.
* @param code the full OTP code whose characters are distributed across the found inputs.
*/
@SuppressWarnings("unchecked")
public static void fillSplitOtpFields(WebDriver wd, WebElement firstField, String code) {
List<WebElement> fields = Collections.singletonList(firstField);
if (wd instanceof JavascriptExecutor je) {
try {
Object result =
je.executeScript(
"var el = arguments[0];"
+ "var container = el.closest('form') || el.parentElement;"
+ "return container"
+ " ? Array.from(container.querySelectorAll('input[maxlength=\"1\"]'))"
+ " : [el];",
firstField);
if (result instanceof List) {
fields = (List<WebElement>) result;
}
} catch (Exception e) {
LOGGER.debug("Could not locate split OTP fields via JS: {}", e.getMessage());
}
}
if (fields.size() != code.length()) {
LOGGER.warn(
"Split OTP field count ({}) differs from code length ({}); filling {} digit(s).",
fields.size(),
code.length(),
Math.min(fields.size(), code.length()));
}
for (int i = 0; i < Math.min(fields.size(), code.length()); i++) {
fields.get(i).sendKeys(String.valueOf(code.charAt(i)));
}
}

public static void fillUserName(
AuthenticationDiagnostics diags,
WebDriver wd,
String username,
WebElement field,
int stepDelayInSecs) {
fillField(field, username);
fillFieldWithEvents(field, username, wd);
diags.recordStep(
wd,
Constant.messages.getString("authhelper.auth.method.diags.steps.username"),
Expand All @@ -813,7 +900,7 @@ public static void fillPassword(
String password,
WebElement field,
int stepDelayInSecs) {
fillField(field, password);
fillFieldWithEvents(field, password, wd);
diags.recordStep(
wd,
Constant.messages.getString("authhelper.auth.method.diags.steps.password"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Zed Attack Proxy (ZAP) and its related class files.
*
* ZAP is an HTTP/HTTPS proxy for assessing web application security.
*
* Copyright 2025 The ZAP Development Team
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.zaproxy.addon.authhelper.internal;

import java.util.function.Supplier;

/**
* Holds state shared across {@link AuthenticationStep} executions within a single authentication
* attempt.
*
* <p>Use {@link #getOrGenerateTotpCode(Supplier)} to obtain the TOTP code for the attempt. The
* code is generated lazily the first time it is requested, ensuring it is as fresh as possible and
* is not computed before potentially time-consuming preceding steps run.
*/
public class AuthenticationContext {

private String cachedTotpCode;

/**
* Tracks how many split-OTP ({@code maxlength="1"}) TOTP_FIELD steps have executed in this
* attempt.
*
* <ul>
* <li>Index 0 — first step: use {@code fillSplitOtpFields} to auto-fill all boxes at once
* (single-step YAML) or fill all boxes before individual steps take over.
* <li>Index 1-N — subsequent per-digit steps: fill only the character at this index into the
* specific targeted box (multi-step YAML, one step per digit).
* </ul>
*/
private int splitOtpCharIndex = 0;

/**
* Returns the TOTP code for this authentication attempt, generating it on first call using the
* supplied {@code generator}.
*
* <p>The code is cached after first generation so that all split single-character OTP inputs
* within the same attempt receive the same value, avoiding mismatches when steps cross a TOTP
* window boundary.
*
* @param generator produces the TOTP code string; called at most once per instance.
* @return the TOTP code.
*/
public String getOrGenerateTotpCode(Supplier<String> generator) {
if (cachedTotpCode == null) {
cachedTotpCode = generator.get();
}
return cachedTotpCode;
}

/**
* Returns the current split-OTP character index and increments it for the next call.
*
* <p>Called once per TOTP_FIELD step that targets a {@code maxlength="1"} input:
*
* <ul>
* <li>Returns 0 on the first call — caller should use {@code fillSplitOtpFields} to fill all
* boxes at once. This covers both the single-step YAML case and the first step of a
* multi-step YAML.
* <li>Returns 1-N on subsequent calls — caller should fill only {@code
* totpCode.charAt(index)} into its specific box. This covers the remaining steps of a
* multi-step YAML where each step targets one digit box.
* </ul>
*
* @return the zero-based index of this split-OTP step within the current attempt.
*/
public int nextSplitOtpCharIndex() {
return splitOtpCharIndex++;
}

/**
* Returns the current split-OTP character index without incrementing it.
*
* <p>Used to decide before the element lookup whether this TOTP_FIELD step should
* be skipped entirely (charIndex &gt; 0 means step 0 already filled all boxes).
*
* @return the zero-based index of the next split-OTP step.
*/
public int peekSplitOtpCharIndex() {
return splitOtpCharIndex;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,26 @@ public void setOrder(int order) {
}

public WebElement execute(WebDriver wd, UsernamePasswordAuthenticationCredentials credentials) {
return execute(wd, credentials, new AuthenticationContext());
}

/**
* Executes this step using the supplied {@code ctx} to share state across steps within a
* single authentication attempt.
*
* <p>For {@code TOTP_FIELD} steps the TOTP code is generated lazily through {@code ctx} so it
* is as fresh as possible. If the resolved element has {@code maxlength="1"} the step
* automatically locates all sibling single-character inputs and fills each with one digit.
*
* @param wd the WebDriver instance.
* @param credentials the user credentials.
* @param ctx the authentication context for this attempt.
* @return the element interacted with, or {@code null} for WAIT steps.
*/
public WebElement execute(
WebDriver wd,
UsernamePasswordAuthenticationCredentials credentials,
AuthenticationContext ctx) {
if (getType() == Type.WAIT) {
try {
Thread.sleep(timeout);
Expand All @@ -192,6 +212,13 @@ public WebElement execute(WebDriver wd, UsernamePasswordAuthenticationCredential
return null;
}

// For TOTP_FIELD steps after the first, fillSplitOtpFields already filled all boxes.
// Skip element lookup entirely to avoid timeouts and OTP-component state corruption.
if (getType() == Type.TOTP_FIELD && ctx.peekSplitOtpCharIndex() > 0) {
ctx.nextSplitOtpCharIndex();
return null;
}

By by = createtBy();

WebElement element =
Expand All @@ -204,27 +231,46 @@ public WebElement execute(WebDriver wd, UsernamePasswordAuthenticationCredential
break;

case CUSTOM_FIELD:
AuthUtils.fillField(element, value);
AuthUtils.fillFieldWithEvents(element, value, wd);
break;

case ESCAPE:
element.sendKeys(Keys.ESCAPE);
break;

case PASSWORD:
AuthUtils.fillField(element, credentials.getPassword());
AuthUtils.fillFieldWithEvents(element, credentials.getPassword(), wd);
break;

case RETURN:
element.sendKeys(Keys.RETURN);
break;

case TOTP_FIELD:
element.sendKeys(getTotpCode(credentials));
String totpCode =
ctx.getOrGenerateTotpCode(
() -> getTotpCode(credentials).toString());
int charIndex = ctx.nextSplitOtpCharIndex();
if (charIndex == 0) {
// First (or only) TOTP_FIELD step.
// fillSplitOtpFields auto-detects all split boxes and fills each
// with one digit, so a single step is enough for both single-step
// and multi-step YAML configs.
if ("1".equals(element.getDomAttribute("maxlength"))) {
AuthUtils.fillSplitOtpFields(wd, element, totpCode);
} else {
// Combined input or app-managed OTP component: send the full
// code and let the component distribute it.
AuthUtils.fillFieldWithEvents(element, totpCode, wd);
}
}
// charIndex > 0: all boxes were already filled in step 0 above.
// Doing nothing here prevents OTP-component shift/corruption bugs
// that occur when sendKeys is called on already-filled boxes.
break;

case USERNAME:
AuthUtils.fillField(element, credentials.getUsername());
AuthUtils.fillFieldWithEvents(element, credentials.getUsername(), wd);
break;

default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import org.parosproxy.paros.network.HttpMessage;
import org.zaproxy.addon.authhelper.AuthUtils;
import org.zaproxy.addon.authhelper.AuthenticationDiagnostics;
import org.zaproxy.addon.authhelper.internal.AuthenticationContext;
import org.zaproxy.addon.authhelper.internal.AuthenticationStep;
import org.zaproxy.zap.authentication.UsernamePasswordAuthenticationCredentials;
import org.zaproxy.zap.model.Context;
Expand Down Expand Up @@ -62,6 +63,10 @@ public Result authenticate(
boolean userAdded = false;
boolean pwdAdded = false;

// Shared context for this authentication attempt. Generates the TOTP code lazily
// at the moment the first TOTP_FIELD step runs, keeping it as fresh as possible.
AuthenticationContext ctx = new AuthenticationContext();

Iterator<AuthenticationStep> it = steps.stream().sorted().iterator();
while (it.hasNext()) {
AuthenticationStep step = it.next();
Expand All @@ -73,7 +78,7 @@ public Result authenticate(
break;
}

WebElement element = step.execute(wd, credentials);
WebElement element = step.execute(wd, credentials, ctx);
diags.recordStep(wd, step.getDescription(), element);

switch (step.getType()) {
Expand Down Expand Up @@ -144,7 +149,7 @@ public Result authenticate(
continue;
}

step.execute(wd, credentials);
step.execute(wd, credentials, ctx);
diags.recordStep(wd, step.getDescription());

AuthUtils.sleepMax(
Expand Down
Loading
Loading