Skip to content

Studio: fetch bare hostnames as https instead of refusing them - #7427

Merged
danielhanchen merged 16 commits into
unslothai:mainfrom
NilayYadav:fix-schemeless-url
Jul 27, 2026
Merged

Studio: fetch bare hostnames as https instead of refusing them#7427
danielhanchen merged 16 commits into
unslothai:mainfrom
NilayYadav:fix-schemeless-url

Conversation

@NilayYadav

Copy link
Copy Markdown
Collaborator

_fetch_url_raw refused any URL without an explicit scheme, so google.com and www.google.com the form models naturally emit were blocked. The error also printed the scheme (got ''), which reads like an empty argument and sends you looking in the wrong place.

Bare hosts are now normalized to https:// before validation, and the message shows the URL. Non-http schemes (ftp://, file://, javascript:) are still refused and SSRF checks are unaffected since normalization happens before host resolution

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb0a10e750

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment thread studio/backend/core/inference/tools.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 45a943a963

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8573050ca0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread studio/backend/core/inference/tools.py Outdated
Comment thread studio/backend/core/inference/tools.py Outdated
@NilayYadav

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: b5f1d0b6fd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

danielhanchen and others added 3 commits July 27, 2026 03:51
str.isdigit() is True for digit-class characters int() refuses (superscript
two, circled digit one), so _normalize_url_scheme reached int(port) and raised
ValueError out of _fetch_url_raw, which runs before its try block. A
web_search url of "example.com:<superscript two>" surfaced a generic tool
exception instead of the Blocked: message it returned before this branch.

Match the port against an anchored [0-9]{1,5} instead; the five-digit cap that
kept the range check from converting an unbounded integer is now in the
pattern.
_fetch_url_raw wraps the initial parsed.port in try/except ValueError, but the
redirect hop reads rp.port unguarded, so a server answering
Location: https://example.org:99999/next fell through to the broad handler as
"Failed to fetch URL: Port out of range 0-65535" rather than a deliberate
block. No request is dispatched either way; this just makes the two paths
report the same way.
@danielhanchen

Copy link
Copy Markdown
Member

Thanks for this. The direction is right, and normalizing before _github_repo_readme_api_url in _fetch_page_text is the detail that is easy to miss. I pushed two commits to your branch rather than leaving notes, since both are small.

1. Unicode digits in the port raised instead of blocking

str.isdigit() is True for digit-class characters that int() refuses, so the guard reached int(port) and raised:

_fetch_url_raw("example.com:²")  ->  ValueError: invalid literal for int() with base 10: '²'

Same for example.com:²/x, example.com:①, example.com:1² and //example.com:². The raise happens at the top of _fetch_url_raw, before its try, so it escapes the (error, body, content_type) contract. On main the same input returned a clean Blocked: only http/https URLs are allowed, so this was a regression. The agentic loop's broad except Exception does catch it, but the result is a generic Error: tool raised an exception that loses the Blocked: prefix TOOL_ERROR_PREFIXES keys off.

Replaced isdigit() with an anchored _PORT_RE = re.compile(r"[0-9]{1,5}"). The five digit cap that kept the range check from converting an unbounded integer is now part of the pattern, so example.com: plus 4400 digits still short circuits.

2. The invalid-port guard did not cover redirect targets

_fetch_url_raw wraps the initial parsed.port in try/except ValueError, but the redirect hop reads rp.port unguarded, so Location: https://example.org:99999/next fell through to the broad handler as Failed to fetch URL: Port out of range 0-65535. No request is dispatched either way, this just makes both paths report the same way. That one predates your branch, it was simply the natural place to fix it.

Both changes come with cases in your test_web_fetch_scheme_normalization.py.

What I checked

I merged your branch onto current main first, since main is 32 commits ahead and #7416 rewrote the request construction block inside _fetch_url_raw. It merges clean.

  • Full studio/backend suite on main versus main plus this branch: identical failure set, plus 27 new passing tests.
  • Your test file run against main alone: 13 failed, 14 passed, so it does exercise the fix.
  • Adversarial matrix of 126 URLs across both DNS pinning modes: no case where the dispatched Host header or SNI differs from the hostname _validate_and_resolve_host validated, and no case dispatched despite a non-public resolution. The behaviour diff is 26 newly fetched hosts and zero lost fetches. 127.0.0.1, 169.254.169.254, metadata.google.internal, 169.254.169.254.nip.io and a name resolving to a mix of public and private addresses all stay blocked.
  • Live: google.com fetches, github.com/unslothai/unsloth reaches the README API, ftp://x.com stays blocked.

Left alone, worth a follow up

  • tool_loop_controller.py:216 and studio/frontend/src/components/assistant-ui/tool-ui-web-search.tsx:76 still require an explicit scheme, so every input this PR newly makes fetchable shows Reading page... and Read page instead of the host (new URL("google.com") throws). Under permission_mode="ask" that means the approval card names no destination for exactly the inputs this PR enables.
  • RFC 3986 allows dots in scheme names, so com.acme.app:443/cb?code=x is rewritten to https://com.acme.app:443/cb?code=x. That ambiguity is inherent to accepting bare host:port, but the docstring's claim that real schemes are returned untouched is not quite right for dotted ones.
  • _normalize_url_scheme does not strip, so _fetch_page_text(" google.com") is refused while _web_search strips first. Worth moving the strip down now that normalization lives at the fetch layer.

The formatter expands a signature carrying a spaced kwarg default, which put
the stub opener on eleven lines. **kw absorbs the timeout the fetch loop
passes and leaves the whole stub on four.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0f26234b0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return url
if port and not (_PORT_RE.fullmatch(port) and 1 <= int(port) <= 65535):
return url
return "https://" + rest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-default ports in the Host header

When a bare URL uses the newly supported explicit-port form, such as example.com:8443/path, this normalization reaches the fetch loop, but that loop still sends Host: example.com rather than Host: example.com:8443. Servers that route or validate requests using the full authority can therefore reject or misroute the request, so the advertised bare host:port case does not reliably work; preserve the non-default port when constructing the Host header.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not reproducible on the current head. #7416 builds the Host header from validated_netloc, which carries the port, so the bare host:port form already advertises the full authority. Measured with the resolver and opener stubbed:

example.com:8443/path           Host='example.com:8443'        request='https://93.184.216.34:8443/path'
https://example.com:8443/path   Host='example.com:8443'        request='https://93.184.216.34:8443/path'
sub.example.co.uk:8080          Host='sub.example.co.uk:8080'  request='https://93.184.216.34:8080'

The bare and explicitly schemed forms produce an identical Host header, and the pinned request URL keeps the port too. This review was against a merge base that predates #7416.

Comment on lines +4177 to +4180
authority = re.split(r"[/?#]", rest, maxsplit = 1)[0]
host, _, port = authority.partition(":")
if not _DOTTED_HOST_RE.fullmatch(host):
return url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize schemeless IPv6 literals

When the direct-fetch input is a valid bare or protocol-relative public IPv6 literal, such as [2606:4700:4700::1111]/ or //[2606:4700:4700::1111]/, splitting the authority at the first colon makes host equal to [2606, so this regex check leaves the URL schemeless and _fetch_url_raw blocks it. The fetch path already supports bracketed IPv6 addresses and the new tests explicitly support schemeless IPv4 literals, so recognize bracketed IPv6 here and let the existing resolution/SSRF validation decide whether it is fetchable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and reproducible on the current head, but leaving it for a separate PR rather than widening the host grammar here.

'[2606:4700:4700::1111]/'   -> Blocked: only http/https URLs are allowed.
'münich.de/path'            -> Blocked: only http/https URLs are allowed.
'example.com.'              -> Blocked: only http/https URLs are allowed.
'https://example.com.'      -> fetched

Reasoning: none of these is a regression. All three were refused before this branch too, so the branch narrows nothing, it just does not widen to cover them. The bug it set out to fix, the misleading (got '') on a plain dotted host, is fixed.

The IDN case in particular deserves its own review rather than a regex widening tacked onto this one. _DOTTED_HOST_RE is the gate in front of an SSRF-sensitive path, and non-ASCII host characters are exactly where urlsplit gets sharp edges:

https://exam/ple.de   (U+FF0F fullwidth solidus)  -> ValueError: netloc contains invalid characters under NFKC normalization
https://exam@ple.de   (U+FF20 fullwidth at)       -> ValueError: netloc contains invalid characters under NFKC normalization
https://example。test (U+3002 ideographic stop)    -> parses, and NFKC-folds to a label separator

So accepting IDN labels means deciding which confusables fold into ., /, @ and : before the authority is split, which is a security decision worth its own PR and its own tests. Same for bracketed IPv6, which changes how the authority is split on :.

Trailing dot is the cheap one of the three and I have no objection to it landing, just not bundled in here.

@danielhanchen

Copy link
Copy Markdown
Member

Correction to my note above, plus what a cross-platform simulation turned up.

Correction

I wrote that the unicode-port raise "loses the Blocked: prefix TOOL_ERROR_PREFIXES keys off". That is not right. TOOL_ERROR_PREFIXES (tool_call_parser.py:124) also contains "Error", so is_tool_error returns True for both the old Error: tool raised an exception ... string and the Blocked: ... string, and nothing branches on Blocked: specifically. Retry and nudge behaviour was not affected.

What the bug actually cost still justifies the fix: an exception escaping the documented (error, body, content_type) contract of _fetch_url_raw, a logger.exception stack trace for ordinary bad input, and a generic message where the model previously got an actionable one.

Simulation

I lifted the shipped _normalize_url_scheme out of tools.py with ast, so the probe cannot drift from the real code, and ran a 3158 input corpus that injects tab, newline, CR, space, C0 controls, backslash, @ # ? : /, percent-encoded delimiters and NFKC confusables at every position of 24 base URLs.

  • Python 3.10, 3.11, 3.12, 3.13 and 3.14, each in its own uv venv: zero differing outputs between versions. That matters because urlsplit strips tab and newline since 3.10 and leading C0 and space since 3.12, while the function decides using urlparse(url) but builds its output from the raw string. The raw-authority fullmatch is what keeps the two in step, since every stripped character falls outside [A-Za-z0-9-].
  • 338 inputs were rewritten, and for every one the resulting hostname contains only [a-z0-9.-] and appears verbatim in the input, so normalization cannot invent or redirect a host. Idempotent on all 3158.
  • No ReDoS. _DOTTED_HOST_RE.fullmatch is linear, 4.7 ms at 64k characters, because the inner group's character classes are disjoint.
  • Locale: the test module passes under LC_ALL=C with PYTHONUTF8=0 and ASCII filesystem encoding, which is the Windows non-UTF8 case for the ² and ٤٤٣ literals.
  • Resolver behaviour per platform: replayed what glibc, musl, macOS and Windows each return for 127.1, 0177.0.0.1 and 0x7f.0.0.1, plus GCE metadata, Docker Desktop 192.168.65.2, in-cluster 10.96.0.1, WSL2 172.20.0.1, IPv6 loopback, and split-horizon DNS answering with one public and one private address. Every variant blocked or failed cleanly with nothing dispatched.
  • The web-fetch modules pass on all five interpreters (178 tests, 98 on 3.14 where pymupdf has no wheel yet).

Two pre-existing issues the corpus surfaced

Both behave identically on main, so neither belongs in this PR.

  1. urlparse raises ValueError on netlocs that NFKC-decompose into a delimiter, for example //exam/ple.com. 38 corpus inputs hit it, and _fetch_url_raw raises on all 38 on main and on this branch alike.
  2. _validate_and_resolve_host catches OSError only, but socket.getaddrinfo raises UnicodeError when IDNA encoding fails, so https://münich.example escapes as an uncaught UnicodeError. Not reachable through the bare-host path here, since _DOTTED_HOST_RE is ASCII only.

Happy to send a separate PR for those two if useful.

The URL is model-supplied, so every bad form should come back as one of the
documented (error, body, content_type) strings. Three gaps remained:

urlparse itself raises on an unmatched IPv6 bracket and on a netloc that
NFKC-decomposes into a delimiter (//exam(fullwidth-solidus)ple.com), and both
calls sat outside a guard. getaddrinfo raises UnicodeError, which is a
ValueError and not the OSError _validate_and_resolve_host catches, when IDNA
encoding rejects a hostname.

Over a 3158 URL corpus that injects tabs, newlines, C0 controls, delimiters and
NFKC confusables at every position, main raises 42 times and this raises none.

Also strip surrounding whitespace in _normalize_url_scheme. _web_search already
stripped, but normalization moved down to the fetch layer, so a direct
_fetch_page_text caller did not get it.
status_for_tool and the web-search tool card both required an explicit scheme
before reading the hostname, so every URL this branch newly makes fetchable
showed the generic "Reading page..." and "Read page" instead of the host.
Under permission_mode=ask that means the approval card named no destination for
exactly the inputs the branch enables.

The backend reuses _normalize_url_scheme. The frontend cannot, since new URL()
throws on a bare host, so RE_BARE_HOST mirrors the same grammar: only a dotted
host with an optional in-range port gets the https prefix, leaving /login,
javascript: and userinfo forms to render generically as before.

Also mention bare hostnames in the url parameter description, since they are
part of the accepted interface now.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83b5d07ad1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +4194 to +4195
if not _DOTTED_HOST_RE.fullmatch(host):
return url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize internationalized bare hostnames

When a user or model supplies a valid Unicode hostname without a scheme, such as münich.de/path or 例え.テスト, this ASCII-only match returns the input unchanged, after which _fetch_url_raw rejects it for lacking an HTTP(S) scheme. Explicitly schemed Unicode hosts already proceed through getaddrinfo and its IDNA handling, so the new bare-host behavior should recognize IDN labels as well rather than remaining limited to ASCII domains.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and reproducible on the current head, but leaving it for a separate PR rather than widening the host grammar here.

'[2606:4700:4700::1111]/'   -> Blocked: only http/https URLs are allowed.
'münich.de/path'            -> Blocked: only http/https URLs are allowed.
'example.com.'              -> Blocked: only http/https URLs are allowed.
'https://example.com.'      -> fetched

Reasoning: none of these is a regression. All three were refused before this branch too, so the branch narrows nothing, it just does not widen to cover them. The bug it set out to fix, the misleading (got '') on a plain dotted host, is fixed.

The IDN case in particular deserves its own review rather than a regex widening tacked onto this one. _DOTTED_HOST_RE is the gate in front of an SSRF-sensitive path, and non-ASCII host characters are exactly where urlsplit gets sharp edges:

https://exam/ple.de   (U+FF0F fullwidth solidus)  -> ValueError: netloc contains invalid characters under NFKC normalization
https://exam@ple.de   (U+FF20 fullwidth at)       -> ValueError: netloc contains invalid characters under NFKC normalization
https://example。test (U+3002 ideographic stop)    -> parses, and NFKC-folds to a label separator

So accepting IDN labels means deciding which confusables fold into ., /, @ and : before the authority is split, which is a security decision worth its own PR and its own tests. Same for bracketed IPv6, which changes how the authority is split on :.

Trailing dot is the cheap one of the three and I have no objection to it landing, just not bundled in here.

Deep Research (unslothai#7219) added a website access policy gate, check_url_access,
which requires an http(s) scheme and reads .port inside its own try/except.

Resolved so normalization runs before that gate in both _fetch_url_raw and
_fetch_page_text. Ordering matters twice over: after the gate a bare host is
refused as a non-http URL and this branch does nothing, and the gate is also
what applies the domain allowlist, so normalizing first is what makes
allowedDomains cover google.com the same way it covers https://google.com.

Dropped the guards the gate now subsumes: it validates the scheme, rejects
credentials, percent-encoded netlocs, backslashes and control characters, and
parses the port before either call site reads it, so the initial and redirect
parses can no longer raise. Kept the normalizer's own parse guard, since it
still sees raw model output. Three tests asserted wording the gate now owns and
assert the class of result instead; the gate deliberately does not echo the
URL, which is the credential-safe choice.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02c8cafb12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# URLs the fetch layer accepts.
from core.inference.tools import _normalize_url_scheme

parsed = urlparse(_normalize_url_scheme(url))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch malformed URLs when building the status

When a model emits a URL that makes urlparse raise, such as https://[::1 or //exam/ple.com, _normalize_url_scheme catches the first parse error and returns the original string, but this second unguarded parse raises again. Because ToolLoopController.prepare_call() builds status_text before executing the fetch, the entire tool turn crashes instead of _fetch_url_raw returning the intended user-facing blocked error; handle ValueError here and fall back to Reading page....

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, fixed in 4decdd3.

Confirmed reachable: status_for_tool runs inside prepare_call, and the call site at safetensors_agentic.py:1163 is outside the except Exception that wraps stream_tool_execution, so the ValueError ended the turn before the fetch ran and the user never saw the blocked message. _normalize_url_scheme catches its own parse error and returns the original string, so the second parse still needed guarding, as you said.

One correction for the record: this predates the branch rather than being introduced by it. status_for_tool on origin/main raises identically:

origin/main   'https://[::1'    -> ValueError: Invalid IPv6 URL
origin/main   '//exam/ple.com'  -> ValueError: netloc contains invalid characters under NFKC normalization

Fixing it here anyway, since the badge is a line this branch touches and the rest of the branch already promises no malformed URL escapes as an exception. Guarded the parse with a fall back to Reading page..., plus four regression cases that fail without the guard.

status_for_tool runs inside prepare_call, before the fetch and outside the
handler that wraps tool execution, so a ValueError from urlparse ends the whole
turn instead of letting _fetch_url_raw return its blocked message.
_normalize_url_scheme catches its own parse error and hands back the original
string, so the parse here still has to be guarded.

Reachable with https://[::1 or a host that NFKC-decomposes into a delimiter.
This predates the branch, main raises identically, but the badge is one of the
lines this branch touches and the rest of it already promises no malformed URL
escapes as an exception.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4decdd3051

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


authority = re.split(r"[/?#]", rest, maxsplit = 1)[0]
host, _, port = authority.partition(":")
if not _DOTTED_HOST_RE.fullmatch(host):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept trailing dots in bare hostnames

When the input is a valid fully qualified bare hostname such as example.com. or example.com.:8443/path, this full match fails and leaves the URL schemeless, so check_url_access rejects it instead of fetching it over HTTPS. This is inconsistent with explicitly schemed forms, because normalize_domain() deliberately canonicalizes a terminal DNS root dot with rstrip("."); allow that dot when recognizing the raw bare authority.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and reproducible on the current head, but leaving it for a separate PR rather than widening the host grammar here.

'[2606:4700:4700::1111]/'   -> Blocked: only http/https URLs are allowed.
'münich.de/path'            -> Blocked: only http/https URLs are allowed.
'example.com.'              -> Blocked: only http/https URLs are allowed.
'https://example.com.'      -> fetched

Reasoning: none of these is a regression. All three were refused before this branch too, so the branch narrows nothing, it just does not widen to cover them. The bug it set out to fix, the misleading (got '') on a plain dotted host, is fixed.

The IDN case in particular deserves its own review rather than a regex widening tacked onto this one. _DOTTED_HOST_RE is the gate in front of an SSRF-sensitive path, and non-ASCII host characters are exactly where urlsplit gets sharp edges:

https://exam/ple.de   (U+FF0F fullwidth solidus)  -> ValueError: netloc contains invalid characters under NFKC normalization
https://exam@ple.de   (U+FF20 fullwidth at)       -> ValueError: netloc contains invalid characters under NFKC normalization
https://example。test (U+3002 ideographic stop)    -> parses, and NFKC-folds to a label separator

So accepting IDN labels means deciding which confusables fold into ., /, @ and : before the authority is split, which is a security decision worth its own PR and its own tests. Same for bracketed IPv6, which changes how the authority is split on :.

Trailing dot is the cheap one of the three and I have no objection to it landing, just not bundled in here.

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 4decdd3051

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 8b5bcabd90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The premise of this branch is that models already emit bare hostnames
unprompted, which is why the fetch layer had to stop refusing them. Advertising
the bare form in the tool schema does not enable anything, it just steers models
toward it, and that is the form carrying every edge case: ambiguous with dotted
custom schemes, and unlike an explicit scheme it does not cover IPv6 literals,
IDN or trailing-dot FQDNs.

The fetch layer tolerates bare hosts. The schema should keep recommending a
full URL. This also drops the one change here with no regression test.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 173aedc7ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +6609 to +6610
if not _DOTTED_HOST_RE.fullmatch(host):
return url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Support valid internationalized bare hostnames

When the model supplies a schemeless internationalized domain such as münich.example/path, this ASCII-only match leaves it unchanged, so check_url_access rejects it for lacking an HTTP scheme. The equivalent https://münich.example/path is already accepted and IDNA-normalized by normalize_domain, making bare-host support inconsistent for valid Unicode domains; validate or IDNA-normalize the raw host before deciding whether to prepend HTTPS.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same call as on the earlier thread for this (comment 3654979772), declining again for consistency rather than because it was re-raised.

Your reasoning is right on the detail I went to check: normalize_domain does IDNA-encode at web_access_policy.py:44, so https://münich.example/path really is accepted and punycoded, and the bare form really is inconsistent with it.

It is still not a regression. Bare IDN was refused before this branch too, so nothing narrowed. And _DOTTED_HOST_RE runs before check_url_access, which is the point: the IDNA that makes the schemed form work happens downstream of the gate, so supporting the bare form means widening the pre-gate host grammar, not reusing normalize_domain. That grammar is what decides where the authority ends, and non-ASCII is where urlsplit gets sharp:

https://exam/ple.de   (U+FF0F fullwidth solidus)  -> ValueError: netloc contains invalid characters under NFKC normalization
https://example。test (U+3002 ideographic stop)    -> parses, and folds to a label separator

Deciding which confusables fold into ., /, @ and : before the split is a security decision that deserves its own PR and its own tests, not a character-class widening added to a branch that is otherwise a fetch-layer robustness fix.

const RE_URL = /URL:\s*(.+)/;
const RE_SNIPPET = /Snippet:\s*(.+)/s;
// Mirrors _DOTTED_HOST_RE: the bare hosts the backend fetches as https.
const RE_BARE_HOST = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+(:\d{1,5})?([/?#]|$)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Handle the empty-port form the backend accepts

For inputs such as example.com:/path or //example.com:, the backend intentionally normalizes the empty port and fetches over the default HTTPS port, but this regex requires at least one digit after the colon. The frontend consequently passes the raw string to new URL, interprets example.com: as a non-HTTP scheme, and leaves displayDomain empty, so a successful fetch is mislabeled as “Read page” instead of naming the host.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, fixed in 7a7abbb.

Confirmed the divergence first, backend versus the card, before and after:

                       backend host      card (before)   card (after)
example.com:/path      example.com       ""              example.com
//example.com:         example.com       ""              example.com
example.com:           example.com       ""              example.com

Worth noting the obvious fix would have introduced the opposite bug. Simply allowing an empty port makes example.com:0 match, and new URL accepts port 0, so the card would name a host the backend refuses (its range check is 1-65535). That mismatch was already present before this change. So rather than loosening the digit count I mirrored the backend rule, an empty port or one in 1-65535, and ran the card against the normalizer output for all 35 inputs in its own matrix: 0 mismatches, typecheck clean.

The card's bare-host pattern required at least one digit after the colon, but
the backend fetches an empty port (example.com: and example.com:/path go to the
default HTTPS port), so a successful fetch rendered as "Read page" with no
host.

Allowing an empty port alone would have swung it the other way: example.com:0
is refused by the backend but new URL() accepts it, so the card would have named
a host that is never fetched. That mismatch was there before this change too.
Mirror the backend rule instead, an empty port or one in 1-65535, checked
against every case in the normalizer's own matrix.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a7abbbcfc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return None, b"".join(chunks)


_DOTTED_HOST_RE = re.compile(r"[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize internationalized bare hostnames

When a model supplies a valid internationalized hostname without a scheme, such as münich.de, this ASCII-only pattern rejects it and leaves it unchanged, after which check_url_access returns “only http/https URLs are allowed.” The same hostname works when written as https://münich.de because normalize_domain already converts Unicode domains through IDNA, so the new bare-host behavior should apply that canonicalization rather than excluding all internationalized domains.

Useful? React with 👍 / 👎.

@danielhanchen
danielhanchen merged commit 1915ca9 into unslothai:main Jul 27, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants