-
Notifications
You must be signed in to change notification settings - Fork 29.4k
[SPARK-59353][UI] Avoid pre-fetch and crawlers triggering actions in UI #58640
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: master
Are you sure you want to change the base?
Changes from all commits
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 |
|---|---|---|
|
|
@@ -105,6 +105,21 @@ private[spark] object UI { | |
| .booleanConf | ||
| .createWithDefault(true) | ||
|
|
||
| val UI_KILL_VIA_GET_ENABLED = ConfigBuilder("spark.ui.killViaGetEnabled") | ||
| .doc("Whether the job/stage kill endpoints of the web UI accept HTTP GET requests in " + | ||
| "addition to POST. Unset, this defaults to true when spark.master is yarn, because " + | ||
| "the YARN ResourceManager/AM proxy does not forward POST requests (SPARK-6846), and " + | ||
| "to false everywhere else. Either way the state-changing endpoints require the " + | ||
| "random per-UI CSRF token embedded in the links and forms the UI renders, and " + | ||
| "reject prefetch requests (Purpose/Sec-Purpose/X-Moz headers) and HEAD requests, so " + | ||
| "forged cross-site requests and incidental link fetches cannot trigger them; " + | ||
| "prefetch rejection relies on the prefetcher identifying itself via those headers. " + | ||
| "Introduced in 4.3.0; also available in 3.5.10, 4.0.5, 4.1.4 and 4.2.1; and in all " + | ||
|
Member
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. Could you remove this sentence here and in |
||
| "versions after 4.3.0.") | ||
| .version("4.3.0") | ||
| .booleanConf | ||
| .createOptional | ||
|
|
||
| val UI_THREAD_DUMPS_ENABLED = ConfigBuilder("spark.ui.threadDumpsEnabled") | ||
| .doc("Whether to show a link for executor thread dumps in Stages and Executor pages.") | ||
| .version("1.2.0") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,7 +18,9 @@ | |
| package org.apache.spark.ui | ||
|
|
||
| import java.net.{URI, URL, URLDecoder} | ||
| import java.util.{EnumSet, List => JList} | ||
| import java.nio.charset.StandardCharsets.UTF_8 | ||
| import java.security.{MessageDigest, SecureRandom} | ||
| import java.util.{EnumSet, HexFormat, List => JList, Locale} | ||
|
|
||
| import scala.jdk.CollectionConverters._ | ||
| import scala.language.implicitConversions | ||
|
|
@@ -127,13 +129,25 @@ private[spark] object JettyUtils extends Logging { | |
| contextHandler | ||
| } | ||
|
|
||
| /** Create a handler that always redirects the user to the given path */ | ||
| /** | ||
| * Create a handler that always redirects the user to the given path. | ||
| * | ||
| * @param csrfToken when defined, mark the handler as state-changing and guard it: | ||
| * requests must carry the token as the "csrfToken" parameter or they | ||
| * are rejected with 403 (see isValidCsrfToken), prefetch requests are | ||
| * rejected with 403 (see isPrefetchRequest), and HEAD is refused with | ||
| * 405 instead of being delegated to doGet. A cross-site page can | ||
| * neither read the token out of the UI's pages (same-origin policy) | ||
| * nor guess it, so it cannot forge a request. Set this for handlers | ||
| * that change state (e.g. the job/stage kill endpoints). | ||
| */ | ||
| def createRedirectHandler( | ||
| srcPath: String, | ||
| destPath: String, | ||
| beforeRedirect: HttpServletRequest => Unit = x => (), | ||
| basePath: String = "", | ||
| httpMethods: Set[String] = Set("GET")): ServletContextHandler = { | ||
| httpMethods: Set[String] = Set("GET"), | ||
| csrfToken: Option[String] = None): ServletContextHandler = { | ||
| val prefixedDestPath = basePath + destPath | ||
| val servlet = new HttpServlet { | ||
| override def doGet(request: HttpServletRequest, response: HttpServletResponse): Unit = { | ||
|
|
@@ -150,7 +164,26 @@ private[spark] object JettyUtils extends Logging { | |
| response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED) | ||
| } | ||
| } | ||
| // HEAD must be safe (RFC 9110) but HttpServlet.doHead delegates to doGet, which would | ||
| // run beforeRedirect; refuse to route HEAD to a state-changing handler at all. | ||
| protected override def doHead(req: HttpServletRequest, res: HttpServletResponse): Unit = { | ||
| if (csrfToken.isDefined) { | ||
| res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED) | ||
| } else { | ||
| super.doHead(req, res) | ||
| } | ||
| } | ||
| private def doRequest(request: HttpServletRequest, response: HttpServletResponse): Unit = { | ||
| if (csrfToken.isDefined && isPrefetchRequest(request)) { | ||
| response.sendError(HttpServletResponse.SC_FORBIDDEN, | ||
| "Prefetch request rejected on a state-changing endpoint.") | ||
| return | ||
| } | ||
| if (!csrfToken.forall(isValidCsrfToken(request, _))) { | ||
| response.sendError(HttpServletResponse.SC_FORBIDDEN, | ||
| "Missing or invalid CSRF token on a state-changing endpoint.") | ||
| return | ||
| } | ||
| beforeRedirect(request) | ||
| // Make sure we don't end up with "//" in the middle | ||
| val requestURL = new URI(request.getRequestURL.toString).toURL | ||
|
|
@@ -167,6 +200,44 @@ private[spark] object JettyUtils extends Logging { | |
| createServletHandler(srcPath, servlet, basePath) | ||
| } | ||
|
|
||
| /** | ||
| * True when the request identifies itself as a prefetch rather than a deliberate user | ||
| * navigation: Chrome and derivatives send "Sec-Purpose: prefetch" (or the older | ||
| * "Purpose: prefetch", and compound values such as "prefetch;prerender"), and Firefox | ||
| * sends "X-Moz: prefetch". State-changing endpoints reject these so that a link | ||
| * prefetcher cannot trigger the action: a prefetch of a kill link must not kill. | ||
| */ | ||
| private[spark] def isPrefetchRequest(request: HttpServletRequest): Boolean = { | ||
| val purpose = Option(request.getHeader("Sec-Purpose")) | ||
| .orElse(Option(request.getHeader("Purpose"))) | ||
| purpose.exists(_.toLowerCase(Locale.ROOT).contains("prefetch")) || | ||
| request.getHeader("X-Moz") != null | ||
|
Member
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. Minor consistency nit: |
||
| } | ||
|
|
||
| /** | ||
| * A fresh token for guarding a UI's state-changing endpoints. 128 random bits, which a | ||
| * cross-site page cannot guess; see createRedirectHandler and isValidCsrfToken. | ||
| */ | ||
| private[spark] def newCsrfToken(): String = { | ||
| val bytes = new Array[Byte](16) | ||
| new SecureRandom().nextBytes(bytes) | ||
| HexFormat.of().formatHex(bytes) | ||
| } | ||
|
|
||
| /** | ||
| * Synchronizer-token check for state-changing endpoints: the request must carry the | ||
| * per-UI random token (embedded in the links and forms the UI renders) as the | ||
| * "csrfToken" parameter. A cross-site page cannot read the token out of the UI's pages | ||
| * (same-origin policy) and cannot guess it, so forged kill/hold requests fail here | ||
| * regardless of which headers the browser sends or suppresses. Constant-time | ||
| * comparison, though the token is not exactly timing-sensitive at 128 bits. | ||
| */ | ||
| private[spark] def isValidCsrfToken(request: HttpServletRequest, expected: String): Boolean = { | ||
| Option(request.getParameter("csrfToken")).exists { provided => | ||
| MessageDigest.isEqual(provided.getBytes(UTF_8), expected.getBytes(UTF_8)) | ||
| } | ||
| } | ||
|
|
||
| /** Create a handler for serving files from a static directory */ | ||
| def createStaticHandler(resourceBase: String, path: String): ServletContextHandler = { | ||
| val contextHandler = new ServletContextHandler | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import org.apache.spark.internal.Logging | |
| import org.apache.spark.internal.LogKeys.{CLASS_NAME, WEB_URL} | ||
| import org.apache.spark.internal.config.DRIVER_LOG_LOCAL_DIR | ||
| import org.apache.spark.internal.config.UI._ | ||
| import org.apache.spark.launcher.SparkLauncher | ||
| import org.apache.spark.scheduler._ | ||
| import org.apache.spark.status.AppStatusStore | ||
| import org.apache.spark.status.api.v1._ | ||
|
|
@@ -55,6 +56,8 @@ private[spark] class SparkUI private ( | |
|
|
||
| val killEnabled = sc.map(_.conf.get(UI_KILL_ENABLED)).getOrElse(false) | ||
|
|
||
| val killViaGetEnabled = SparkUI.killViaGetEnabled(conf) | ||
|
|
||
| val holdEnabled = sc.map(_.conf.get(UI_HOLD_ENABLED)).getOrElse(false) | ||
|
|
||
| var appId: String = _ | ||
|
|
@@ -121,16 +124,24 @@ private[spark] class SparkUI private ( | |
| attachHandler(PrometheusResource.getServletHandler(this)) | ||
| } | ||
|
|
||
| // These should be POST only, but, the YARN AM proxy won't proxy POSTs | ||
| // These endpoints change state, so they require the per-UI CSRF token and reject | ||
| // prefetch requests (see JettyUtils.createRedirectHandler). Kill also accepts GET | ||
| // where a proxy in front of the UI cannot forward POST -- the token rides in the | ||
| // request parameters, which such proxies do forward. See SparkUI.killViaGetEnabled. | ||
| val killHttpMethods: Set[String] = | ||
| if (killViaGetEnabled) Set("GET", "POST") else Set("POST") | ||
| attachHandler(createRedirectHandler( | ||
| "/jobs/job/kill", "/jobs/", jobsTab.handleKillRequest, httpMethods = Set("GET", "POST"))) | ||
| "/jobs/job/kill", "/jobs/", jobsTab.handleKillRequest, httpMethods = killHttpMethods, | ||
| csrfToken = Some(csrfToken))) | ||
| attachHandler(createRedirectHandler( | ||
| "/stages/stage/kill", "/stages/", stagesTab.handleKillRequest, | ||
| httpMethods = Set("GET", "POST"))) | ||
| httpMethods = killHttpMethods, csrfToken = Some(csrfToken))) | ||
| attachHandler(createRedirectHandler( | ||
| "/jobs/hold", "/jobs/", jobsTab.handleHoldRequest, httpMethods = Set("GET", "POST"))) | ||
| "/jobs/hold", "/jobs/", jobsTab.handleHoldRequest, httpMethods = Set("GET", "POST"), | ||
|
Member
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.
|
||
| csrfToken = Some(csrfToken))) | ||
| attachHandler(createRedirectHandler( | ||
| "/jobs/resume", "/jobs/", jobsTab.handleResumeRequest, httpMethods = Set("GET", "POST"))) | ||
| "/jobs/resume", "/jobs/", jobsTab.handleResumeRequest, httpMethods = Set("GET", "POST"), | ||
| csrfToken = Some(csrfToken))) | ||
| } | ||
|
|
||
| initialize() | ||
|
|
@@ -256,6 +267,16 @@ private[spark] object SparkUI { | |
| conf.get(UI_PORT) | ||
| } | ||
|
|
||
| /** | ||
| * Whether the kill endpoints accept GET as well as POST. Unset, this follows the cluster | ||
| * manager: on for YARN, whose ResourceManager/AM proxy does not forward POST | ||
| * (SPARK-6846), and off everywhere else. | ||
| */ | ||
| def killViaGetEnabled(conf: SparkConf): Boolean = { | ||
| conf.get(UI_KILL_VIA_GET_ENABLED) | ||
| .getOrElse(conf.get(SparkLauncher.SPARK_MASTER, null) == "yarn") | ||
| } | ||
|
|
||
| /** | ||
| * Create a new UI backed by an AppStatusStore. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -260,7 +260,9 @@ private[ui] class AllJobsPage(parent: JobsTab, store: AppStatusStore) extends We | |
| UIUtils.prependBaseUri(request, parent.basePath), | ||
| "jobs", // subPath | ||
| killEnabled, | ||
| jobIdTitle | ||
| jobIdTitle, | ||
| parent.killViaGetEnabled, | ||
| parent.csrfToken | ||
| ).table(jobPage) | ||
| } catch { | ||
| case e @ (_ : IllegalArgumentException | _ : IndexOutOfBoundsException) => | ||
|
|
@@ -373,7 +375,8 @@ private[ui] class AllJobsPage(parent: JobsTab, store: AppStatusStore) extends We | |
| <li> | ||
| <strong>Application:</strong> | ||
| {status} | ||
| <a href={s"$basePathUri/jobs/$action/"} role="button" | ||
| <a href={s"$basePathUri/jobs/$action/?csrfToken=${parent.csrfToken}"} | ||
| role="button" | ||
| data-confirm-message={confirm} | ||
| class="btn btn-sm btn-outline-secondary confirm-link">{label}</a> | ||
| {parent.lastHoldRequestStatus.getOrElse("")} | ||
|
|
@@ -554,7 +557,9 @@ private[ui] class JobPagedTable( | |
| basePath: String, | ||
| subPath: String, | ||
| killEnabled: Boolean, | ||
| jobIdTitle: String | ||
| jobIdTitle: String, | ||
| killViaGetEnabled: Boolean, | ||
| csrfToken: String | ||
| ) extends PagedTable[JobTableRowData] { | ||
|
|
||
| private val (sortColumn, desc, pageSize) = getTableParameters(request, jobTag, jobIdTitle) | ||
|
|
@@ -613,11 +618,27 @@ private[ui] class JobPagedTable( | |
| val job = jobTableRow.jobData | ||
|
|
||
| val killLink = if (killEnabled) { | ||
| // SPARK-6846 this should be POST-only but YARN AM won't proxy POST | ||
| val killLinkUri = s"$basePath/jobs/job/kill/?id=${job.jobId}" | ||
| <a href={killLinkUri} role="button" | ||
| data-kill-message={s"Are you sure you want to kill job ${job.jobId} ?"} | ||
| class="btn btn-sm btn-outline-danger kill-link float-end">Kill</a> | ||
| val killMessage = s"Are you sure you want to kill job ${job.jobId} ?" | ||
| if (killViaGetEnabled) { | ||
| // Default: a plain GET link, which also works through proxies that do not forward | ||
|
Member
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. nit. This comment is stale because GET is no longer the default outside YARN. The same comment exists in |
||
| // POST, such as the YARN ResourceManager/AM proxy (SPARK-6846). The endpoint | ||
| // requires the CSRF token and rejects prefetch requests (see SparkUI.initialize), | ||
| // and webui.js gates the click on the confirmation dialog. | ||
| <a href={s"$basePath/jobs/job/kill/?id=${job.jobId}&csrfToken=$csrfToken"} | ||
| role="button" | ||
| data-kill-message={killMessage} | ||
| class="btn btn-sm btn-outline-danger kill-link float-end">Kill</a> | ||
| } else { | ||
| // POST-only mode (spark.ui.killViaGetEnabled=false): submit the kill as a form, | ||
| // the same pattern the master UI uses for killing applications and drivers. | ||
| <form action={s"$basePath/jobs/job/kill/"} method="POST" class="d-inline float-end"> | ||
| <input type="hidden" name="id" value={job.jobId.toString}/> | ||
| <input type="hidden" name="csrfToken" value={csrfToken}/> | ||
| <button type="submit" | ||
| data-kill-message={killMessage} | ||
| class="btn btn-sm btn-outline-danger kill-link">Kill</button> | ||
| </form> | ||
| } | ||
| } else { | ||
| Seq.empty | ||
| } | ||
|
|
||
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.
This new config needs
.withBindingPolicy(...). This is the cause of theSparkConfigBindingPolicySuitefailure (Config enforcement for bindingPolicy) in thehive - other testsjob. LikeUI_HOLD_ENABLEDabove,.withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)should work.