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
10 changes: 9 additions & 1 deletion src/phoenix/server/api/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,7 +1022,15 @@ async def node(self, id: strawberry.ID, info: Info[Context, None]) -> Node:
dataset_example_rowid=dataset_example_rowid,
)

global_id = GlobalID.from_id(id)
# `GlobalID.from_id` itself calls `b64decode` and raises
# `GlobalIDValueError` on non-base64 input. Bare project names
# (e.g. /projects/default/* URLs) reach this code path; surface a
# friendly NotFound instead of leaking a binascii padding error.
# See https://github.com/Arize-ai/phoenix/issues/12908
try:
global_id = GlobalID.from_id(id)
except Exception:
raise NotFound(f"Unknown node: {id}")
type_name = global_id.type_name
if type_name == Secret.__name__:
return Secret(id=global_id.node_id)
Expand Down
13 changes: 12 additions & 1 deletion src/phoenix/server/api/types/node.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import binascii
import re
from base64 import b64decode

Expand All @@ -7,7 +8,17 @@


def is_composite_global_id(node_id: str) -> bool:
decoded_node_id = b64decode(node_id).decode()
"""Return True if `node_id` is a base64-encoded composite global id.

Strings that are not valid base64 (e.g. literal project names like
"default" passed in via /projects/<name>/* URLs) are not composite
global ids — return False rather than raising. See
https://github.com/Arize-ai/phoenix/issues/12908
"""
try:
decoded_node_id = b64decode(node_id, validate=True).decode()
except (binascii.Error, UnicodeDecodeError, ValueError):
return False
return _COMPOSITE_GLOBAL_ID_PATTERN.match(decoded_node_id) is not None


Expand Down
54 changes: 53 additions & 1 deletion tests/unit/server/api/types/test_node.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from base64 import b64encode

import pytest
from strawberry.relay import GlobalID

from phoenix.server.api.types.node import from_global_id, from_global_id_with_expected_type
from phoenix.server.api.types.node import (
from_global_id,
from_global_id_with_expected_type,
is_composite_global_id,
)


def test_from_global_id_returns_type_name_and_node_id() -> None:
Expand All @@ -21,3 +27,49 @@ def test_from_global_id_with_expected_type_raises_value_error_for_unexpected_typ
global_id = GlobalID(type_name="EmbeddingDimension", node_id=str(1))
with pytest.raises(ValueError):
from_global_id_with_expected_type(global_id=global_id, expected_type_name="Dimension")


# Regression tests for https://github.com/Arize-ai/phoenix/issues/12908 -- bare
# project names (e.g. "default") reach the `node` resolver via
# /projects/<name>/* URLs and previously raised `binascii.Error: Incorrect
# padding` from inside `is_composite_global_id`.


def test_is_composite_global_id_returns_false_for_non_base64_input() -> None:
"""Literal project names like "default" must not raise."""
assert is_composite_global_id("default") is False


@pytest.mark.parametrize(
"node_id",
[
"default", # invalid base64 alphabet char ("u") + wrong padding
"my-project-name",
"abc", # 3 chars -- wrong base64 length
"", # empty
"===", # only padding
"not!base64", # invalid base64 char
],
)
def test_is_composite_global_id_handles_invalid_base64(node_id: str) -> None:
"""All invalid base64 inputs return False instead of raising."""
assert is_composite_global_id(node_id) is False


def test_is_composite_global_id_returns_false_for_simple_global_id() -> None:
"""Standard non-composite ids (b64 of "Project:1") are not composite."""
simple = b64encode(b"Project:1").decode()
assert is_composite_global_id(simple) is False


def test_is_composite_global_id_returns_true_for_composite_id() -> None:
"""A composite id has more than one ':' separator after base64 decode."""
composite = b64encode(b"ExperimentRepeatedRunGroup:1:2").decode()
assert is_composite_global_id(composite) is True


def test_is_composite_global_id_handles_non_utf8_decoded_bytes() -> None:
"""Random base64 that decodes to non-UTF-8 bytes must not raise."""
# b64encode(b"\xff\xff\xff") -> decodes to non-UTF-8 bytes
non_utf8 = b64encode(b"\xff\xff\xff").decode()
assert is_composite_global_id(non_utf8) is False
Loading