2,191 prompts from top AI collections
← Back to Market
[langflow-ai/langflow] flow secrets
Universal API Leak/Open
14,609 characters
"""Metadata-driven secret-scrubbing helpers for serialized flow data. This module is deliberately independent of the API and service layers so flow payloads can be scrubbed at any export boundary without importing FastAPI. The scrubber removes values identified by field metadata, secret-shaped names, and credential-bearing URL structure. It cannot prove that an arbitrary value stored in an otherwise ordinary field is not a secret. """ from __future__ import annotations import re from copy import deepcopy from urllib.parse import parse_qsl, urlsplit API_WORDS = ["api", "key", "token"] _ASCII_CONTROL_CUTOFF = 0x20 _ASCII_DELETE = 0x7F _VARIABLE_REFERENCE_MAX_LENGTH = 256 # Per-row override of a table column's ``load_from_db`` flag, written by the # table cell editor and read by ``lfx.interface.initialize.loading``. Duplicated # rather than imported to keep this module free of runtime dependencies. _TABLE_LOAD_FROM_DB_FIELDS = "__load_from_db_fields" # Defense-in-depth for several widely used credential formats, not an exhaustive # provider catalog. ``load_from_db`` is the required reference marker, while # this additional check rejects a matching free-form variable name rather than # risk packaging a raw credential from inconsistent metadata. _CREDENTIAL_VALUE_PATTERN = re.compile( r"""^(?: sk-[A-Za-z0-9_-]{8,} | (?:ghp|gho|ghs|ghu)_[A-Za-z0-9]{8,} | github_pat_[A-Za-z0-9_]{8,} | glpat-[A-Za-z0-9_-]{8,} | hf_[A-Za-z0-9]{8,} | xox[abps]-[A-Za-z0-9-]{8,} | (?:AKIA|ASIA)[0-9A-Z]{16} )$""", re.VERBOSE, ) _SECRET_NAME_PARTS = frozenset({"credential", "credentials", "passwd", "password", "secret"}) _SECRET_COMPOUND_NAMES = frozenset( { "access_key", "api_key", "apikey", "authorization", "client_secret", "connection_string", "cookie", "database_uri", "database_url", "dsn", "private_key", "proxy_authorization", "set_cookie", } ) def has_api_terms(word: str) -> bool: """Return whether a field name identifies an API credential.""" return "api" in word and ("key" in word or ("token" in word and "tokens" not in word)) def remove_api_keys(flow: dict) -> dict: """Null legacy password-marked API key fields in a serialized flow.""" flow_data = flow.get("data") if not isinstance(flow_data, dict): return flow nodes = flow_data.get("nodes") if not isinstance(nodes, list): return flow for node in nodes: if not isinstance(node, dict): continue node_data = node.get("data") if not isinstance(node_data, dict): continue node_inner = node_data.get("node") if not isinstance(node_inner, dict): continue template = node_inner.get("template") if not isinstance(template, dict): continue for value in template.values(): if not isinstance(value, dict): continue name = value.get("name") if isinstance(name, str) and has_api_terms(name) and value.get("password"): value["value"] = None return flow def strip_secret_field_values(flow_data: dict | None) -> dict | None: """Return a deep-copied flow-data mapping with persisted secrets removed.""" if flow_data is None: return flow_data return strip_secret_field_values_in_place(deepcopy(flow_data)) def _normalized_secret_name(value: object) -> str: """Normalize snake, kebab, and camel-case names for classification.""" name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(value or "")) return re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower() def _is_secret_name(value: object) -> bool: normalized = _normalized_secret_name(value) if normalized in _SECRET_COMPOUND_NAMES: return True parts = set(normalized.split("_")) is_token_value = normalized == "token" or normalized.endswith("_token") return bool(parts & _SECRET_NAME_PARTS) or is_token_value or {"api", "key"}.issubset(parts) def _is_variable_reference(value: object) -> bool: """Return whether a ``load_from_db`` value looks like a global-variable name. A bound field stores the referenced variable's *name* in ``value``, not the secret itself. Values that cannot be a name — empty, oversized, control characters, a non-string, a URL carrying credentials, or a token issued under a well-known credential prefix — are treated as raw secrets instead. Variable names are freeform, so this check narrows the blast radius of a mislabelled field rather than eliminating it: a secret whose text is shaped like an ordinary name is indistinguishable from a real reference here, and a legitimate name shaped like a known credential is rejected fail-closed. The primary signal is the ``load_from_db`` metadata, which the editor keeps in step with whether a value is a name or a literal; these additional shape checks deliberately fail closed when that metadata and the value disagree. """ if not isinstance(value, str) or not value.strip(): return False if len(value) > _VARIABLE_REFERENCE_MAX_LENGTH: return False if any(ord(character) < _ASCII_CONTROL_CUTOFF or ord(character) == _ASCII_DELETE for character in value): return False if _CREDENTIAL_VALUE_PATTERN.match(value.strip()): return False return not _contains_url_credentials(value) def _contains_url_credentials(value: str) -> bool: """Return whether a URL contains userinfo or secret-named parameters.""" try: parsed = urlsplit(value) except ValueError: return False if parsed.username is not None or parsed.password is not None: return True return any( _is_secret_name(key) for component in (parsed.query, parsed.fragment) for key, _ in parse_qsl(component, keep_blank_values=True) ) def _structured_container_frame(value: object): """Prepare one mutable container for bounded, in-place traversal.""" if isinstance(value, dict): discriminator = next( (value.get(key) for key in ("key", "name", "header") if _is_secret_name(value.get(key))), None, ) if discriminator is not None and "value" in value: value["value"] = None return value, iter(value), True if isinstance(value, list): return value, iter(range(len(value))), False return None def _strip_structured_secret_values_in_place(value: object) -> object: """Iteratively null secret-named values without copying wide subtrees.""" if isinstance(value, str) and _contains_url_credentials(value): return None root_frame = _structured_container_frame(value) if root_frame is None: return value frames = [root_frame] while frames: container, keys, classify_keys = frames[-1] try: key = next(keys) except StopIteration: frames.pop() continue if classify_keys and _is_secret_name(key): container[key] = None continue nested_value = container[key] if isinstance(nested_value, str) and _contains_url_credentials(nested_value): container[key] = None continue nested_frame = _structured_container_frame(nested_value) if nested_frame is not None: frames.append(nested_frame) return value def _cell_loads_from_db(row_metadata: object, column: str) -> bool | None: """Return one row's explicit ``load_from_db`` choice for a column. Mirrors ``cell_load_from_db`` in ``lfx.interface.initialize.loading``: a row may override its schema column per cell, so the two must agree on which cells resolve to a variable and which hold a literal. ``None`` means the row records no choice, which the runtime resolves from the database. """ if isinstance(row_metadata, dict): return bool(row_metadata[column]) if column in row_metadata else None if isinstance(row_metadata, list): return column in row_metadata return None def _table_reference_columns(field: dict) -> frozenset[str]: """Return table columns whose cells hold global-variable name references.""" schema = field.get("table_schema") if not isinstance(schema, list): return frozenset() return frozenset( column["name"] for column in schema if isinstance(column, dict) and column.get("load_from_db") and isinstance(column.get("name"), str) ) def _strip_table_rows_in_place(field: dict, reference_columns: frozenset[str], variable_references: set[str]) -> None: """Strip table rows while preserving valid ``load_from_db`` column references.""" rows = field.get("value") if not isinstance(rows, list): field["value"] = _strip_structured_secret_values_in_place(rows) return for index, row in enumerate(rows): if not isinstance(row, dict): rows[index] = _strip_structured_secret_values_in_place(row) continue # Detach the per-cell metadata so the generic scrub cannot null an entry # keyed by a secret-named column and silently turn a reference cell into # a literal one for the deployment target. row_metadata = row.pop(_TABLE_LOAD_FROM_DB_FIELDS, None) preserved: dict[str, str | None] = {} for column in reference_columns & row.keys(): cell = row[column] # Only a cell the runtime resolves from the database holds a # variable *name*. A cell the row marks as not loading from the # database holds the literal value itself, so it is scrubbed like # any other secret rather than published as a required variable. if _cell_loads_from_db(row_metadata, column) is False or not _is_variable_reference(cell): preserved[column] = None continue variable_references.add(cell) preserved[column] = cell _strip_structured_secret_values_in_place(row) row.update(preserved) if row_metadata is not None: row[_TABLE_LOAD_FROM_DB_FIELDS] = row_metadata def _strip_template_field_value(field: dict, variable_references: set[str] | None = None) -> None: """Strip a template field according to metadata and value shape.""" if ( variable_references is not None and field.get("load_from_db") and not isinstance(field.get("value"), (dict, list)) ): # A bound field stores the global-variable *name*, not the secret, so a # deployment target can re-resolve the credential it provisions under # that name. Anything that fails the reference shape check is nulled. value = field.get("value") if _is_variable_reference(value): variable_references.add(value) else: field["value"] = None return if field.get("password") or _is_secret_name(field.get("name")): field["value"] = None return field_type = str(field.get("type") or "").lower() input_type = str(field.get("_input_type") or "").lower() if field_type == "mcp" or input_type == "mcpinput": value = field.get("value") name = value.get("name") if isinstance(value, dict) else None field["value"] = {"name": name} if name else None return if variable_references is not None: reference_columns = _table_reference_columns(field) if reference_columns: _strip_table_rows_in_place(field, reference_columns, variable_references) return field["value"] = _strip_structured_secret_values_in_place(field.get("value")) def _strip_secrets_from_nodes(nodes: list, variable_references: set[str] | None = None) -> None: """Iteratively strip secret values from regular and grouped flow nodes.""" node_frames = [iter(nodes)] while node_frames: try: node = next(node_frames[-1]) except StopIteration: node_frames.pop() continue if not isinstance(node, dict): continue node_data = node.get("data") if not isinstance(node_data, dict): continue node_inner = node_data.get("node") if not isinstance(node_inner, dict): continue template = node_inner.get("template") if isinstance(template, dict): for value in template.values(): if isinstance(value, dict): _strip_template_field_value(value, variable_references) flow = node_inner.get("flow") if isinstance(flow, dict): nested_flow_data = flow.get("data") if isinstance(nested_flow_data, dict): nested_nodes = nested_flow_data.get("nodes") if isinstance(nested_nodes, list): node_frames.append(iter(nested_nodes)) def strip_secret_field_values_in_place( flow_data: dict | None, *, variable_references: set[str] | None = None, ) -> dict | None: """Scrub a detached flow-data mapping in place with bounded traversal memory. By default every secret-bearing value is nulled, including the names of global variables bound via ``load_from_db`` — the right contract for anonymous consumers such as the public-flow endpoint. Deployment packaging passes ``variable_references``: fields (and table cells) that the runtime resolves from the database then keep their variable-*name* values, and every preserved name is added to the set so the caller can emit a required-variables manifest. Only values the runtime would look up are preserved — a table cell the row marks as not loading from the database holds the literal secret, so it is nulled like any other. Values that cannot be a variable name are nulled too, but that shape check narrows rather than closes the gap: a flow whose ``load_from_db`` metadata is wrong can still carry a raw secret shaped like an ordinary name. """ if not flow_data: return flow_data nodes = flow_data.get("nodes") if isinstance(nodes, list): _strip_secrets_from_nodes(nodes, variable_references) return flow_data __all__ = [ "API_WORDS", "has_api_terms", "remove_api_keys", "strip_secret_field_values", "strip_secret_field_values_in_place", ]
Download .txt