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 @@ -31,6 +31,7 @@ import org.apache.linkis.server.security.SecurityFilter.logger
import org.apache.linkis.server.security.SSOUtils.sslEnable

import org.apache.commons.lang3.StringUtils
import org.apache.commons.net.util.SubnetUtils

import javax.servlet._
import javax.servlet.http.{Cookie, HttpServletRequest, HttpServletResponse}
Expand Down Expand Up @@ -151,14 +152,30 @@ object SecurityFilter {
private[linkis] val OTHER_SYSTEM_IGNORE_UM_USER = "dataworkcloud_rpc_user"
private[linkis] val ALLOW_ACCESS_WITHOUT_TIMEOUT = "dataworkcloud_inner_request"

// CVE-2026-XXXX: trusted internal source CIDRs for gateway-to-backend forwarding.
// Default: loopback + RFC1918. Override per-deployment to add gateway/load-balancer IPs.
private val trustedInternalSources: Array[String] =
CommonVars(
"linkis.security.trusted.internal.sources",
"127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
).getValue.split(",").map(_.trim).filter(_.nonEmpty)

private val requireInternalIp: Boolean =
CommonVars("linkis.security.ignore.timeout.require.internal.ip", "true").getValue.toBoolean

def getLoginUserThrowsExceptionWhenTimeout(req: HttpServletRequest): Option[String] =
Option(req.getCookies)
.flatMap(cs => SSOUtils.getLoginUser(cs))
.orElse(
SSOUtils
.getLoginUserIgnoreTimeout(key => Option(req.getHeader(key)))
.filter(_ == OTHER_SYSTEM_IGNORE_UM_USER)
)
.orElse {
// Header-based internal RPC fallback must only be honoured for
// requests from trusted internal IPs. Reject external forgeries.
val clientIp = getClientIp(req)
if (!isTrustedInternal(clientIp)) None
else
SSOUtils
.getLoginUserIgnoreTimeout(key => Option(req.getHeader(key)))
.filter(_ == OTHER_SYSTEM_IGNORE_UM_USER)
}

def getLoginUser(req: HttpServletRequest): Option[String] =
Utils.tryCatch(getLoginUserThrowsExceptionWhenTimeout(req)) {
Expand All @@ -174,9 +191,42 @@ object SecurityFilter {
case t => throw t
}

def isRequestIgnoreTimeout(req: HttpServletRequest): Boolean = Option(req.getCookies).exists(
_.exists(c => c.getName == ALLOW_ACCESS_WITHOUT_TIMEOUT && c.getValue == "true")
)
// CVE-2026-XXXX (vuln B): the ignore-timeout cookie must only be honoured when
// the request originates from a trusted internal source (gateway or same-network
// peer). External clients cannot set this cookie to trigger the bypass.
def isRequestIgnoreTimeout(req: HttpServletRequest): Boolean = {
val hasCookie = Option(req.getCookies).exists(
_.exists(c => c.getName == ALLOW_ACCESS_WITHOUT_TIMEOUT && c.getValue == "true"))
if (!hasCookie) return false
if (!requireInternalIp) return true // escape hatch for legacy deployments
val ip = getClientIp(req)
if (!isTrustedInternal(ip)) {
logger.warn(
s"Ignore-timeout cookie present but client IP $ip is not in " +
"linkis.security.trusted.internal.sources; rejecting as potential auth bypass")
return false
}
true
}

private def getClientIp(req: HttpServletRequest): String = {
val remote = req.getRemoteAddr
// Honour X-Forwarded-For only when the immediate upstream is already trusted
if (isTrustedInternal(remote)) {
val xff = req.getHeader("X-Forwarded-For")
if (xff != null && xff.nonEmpty) return xff.split(",").head.trim
}
remote
}

private def isTrustedInternal(ip: String): Boolean = {
if (ip == null || ip.isEmpty) return false
if (trustedInternalSources.contains(ip)) return true
trustedInternalSources.exists { cidr =>
try { new SubnetUtils(cidr).getInfo.isInRange(ip) }
catch { case _: Exception => false }
}
}

def addIgnoreTimeoutSignal(response: HttpServletResponse): Unit =
response.addCookie(ignoreTimeoutSignal())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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.apache.linkis.server.security

import org.junit.jupiter.api.{Assertions, DisplayName, Test}
import org.mockito.Mockito.{mock, when}

import javax.servlet.http.{Cookie, HttpServletRequest}

class SecurityFilterTest {

private val IGNORE_COOKIE_NAME = SecurityFilter.ALLOW_ACCESS_WITHOUT_TIMEOUT

private def mockRequest(remoteAddr: String, cookies: Array[Cookie] = null,
xForwardedFor: String = null): HttpServletRequest = {
val req = mock(classOf[HttpServletRequest])
when(req.getRemoteAddr).thenReturn(remoteAddr)
when(req.getCookies).thenReturn(cookies)
if (xForwardedFor != null) {
when(req.getHeader("X-Forwarded-For")).thenReturn(xForwardedFor)
} else {
when(req.getHeader("X-Forwarded-For")).thenReturn(null)
}
req
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsFalseWhenCookieAbsent")
def isRequestIgnoreTimeoutNoCookieTest(): Unit = {
val req = mockRequest("127.0.0.1", cookies = null)
Assertions.assertFalse(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsTrueWhenCookieFromLoopback")
def isRequestIgnoreTimeoutFromLoopbackTest(): Unit = {
val req = mockRequest("127.0.0.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")))
Assertions.assertTrue(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsFalseWhenCookieFromExternalIP")
def isRequestIgnoreTimeoutFromExternalIPTest(): Unit = {
// 203.0.113.0/24 is TEST-NET-3, not in default RFC1918 trusted sources
val req = mockRequest("203.0.113.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")))
Assertions.assertFalse(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsTrueFromPrivate10x")
def isRequestIgnoreTimeoutFromPrivate10xTest(): Unit = {
val req = mockRequest("10.255.255.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")))
Assertions.assertTrue(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsTrueFromPrivate172x")
def isRequestIgnoreTimeoutFromPrivate172xTest(): Unit = {
val req = mockRequest("172.31.0.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")))
Assertions.assertTrue(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_returnsFalseWhenCookieValueIsFalse")
def isRequestIgnoreTimeoutCookieFalseTest(): Unit = {
val req = mockRequest("127.0.0.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "false")))
Assertions.assertFalse(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_honorsXForwardedForFromTrustedUpstream")
def isRequestIgnoreTimeoutWithXForwardedForTest(): Unit = {
val req = mockRequest("127.0.0.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")),
xForwardedFor = "10.0.0.5, 203.0.113.1")
Assertions.assertTrue(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("isRequestIgnoreTimeout_ignoresXForwardedForFromUntrustedUpstream")
def isRequestIgnoreTimeoutXForwardedForUntrustedTest(): Unit = {
val req = mockRequest("203.0.113.1",
cookies = Array(new Cookie(IGNORE_COOKIE_NAME, "true")),
xForwardedFor = "10.0.0.5")
Assertions.assertFalse(SecurityFilter.isRequestIgnoreTimeout(req))
}

@Test
@DisplayName("getLoginUserThrowsExceptionWhenTimeout_rejectsHeaderFallbackFromExternalIP")
def getLoginUserHeaderFallbackFromExternalIPTest(): Unit = {
val req = mockRequest("203.0.113.1", cookies = null)
val result = SecurityFilter.getLoginUserThrowsExceptionWhenTimeout(req)
Assertions.assertTrue(result.isEmpty)
}

@Test
@DisplayName("ignoreTimeoutSignal_createsCookieWithCorrectDefaults")
def ignoreTimeoutSignalDefaultsTest(): Unit = {
val cookie = SecurityFilter.ignoreTimeoutSignal()
Assertions.assertEquals(IGNORE_COOKIE_NAME, cookie.getName)
Assertions.assertEquals("true", cookie.getValue)
Assertions.assertEquals(-1, cookie.getMaxAge)
Assertions.assertEquals("/", cookie.getPath)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,19 @@ object GatewaySSOUtils extends Logging {
case _ => host
}

// CVE-2026-XXXX (vuln B): the gateway is the external trust boundary and
// must never honour the client-controllable dataworkcloud_inner_request
// cookie. Only the header-based OTHER_SYSTEM_IGNORE_UM_USER internal RPC
// path remains as a fallback.
def getLoginUser(gatewayContext: GatewayContext): Option[String] = {
val cookies = getCookies(gatewayContext)
Utils.tryCatch(SSOUtils.getLoginUser(cookies)) {
case _: LoginExpireException
if Option(cookies).exists(
_.exists(c => c.getName == ALLOW_ACCESS_WITHOUT_TIMEOUT && c.getValue == "true")
) =>
case _: LoginExpireException =>
ServerSSOUtils
.getLoginUserIgnoreTimeout(key =>
Option(cookies).flatMap(_.find(_.getName == key).map(_.getValue))
Option(gatewayContext.getRequest.getHeaders.get(key)).flatMap(_.headOption)
)
.filter(_ != OTHER_SYSTEM_IGNORE_UM_USER)
.filter(_ == OTHER_SYSTEM_IGNORE_UM_USER)
case t => throw t
}
}
Expand Down
Loading