Skip to content

Fix URI#host= to wrap IPv6 address in brackets#16164

Merged
straight-shoota merged 8 commits intocrystal-lang:masterfrom
stakach:patch-11
Sep 28, 2025
Merged

Fix URI#host= to wrap IPv6 address in brackets#16164
straight-shoota merged 8 commits intocrystal-lang:masterfrom
stakach:patch-11

Conversation

@stakach
Copy link
Contributor

@stakach stakach commented Sep 23, 2025

The square brackets around IPv6 addresses are only required for URIs

We found this issue when migrating a service to run on IPv6 - this change would make the code compatible with either IPv4 or IPv6 without additional logic changes to the service

This ensures a URI initialized with otherwise valid data is a valid URI

@stakach
Copy link
Contributor Author

stakach commented Sep 23, 2025

let me know if there is interest in this patch and I'll add some specs
You can still shoot your foot if you set uri.host = raw_ipv6 directly too - might make sense to update that as well?

@straight-shoota
Copy link
Member

This is related to #12798: URI currently allows invalid path and invalid host values, and we should validate or fix them.

For consistency, we should always wrap IPv6 addresses, including the host= setter. URI.new(host: "::1") should be equivalent to URI.new.tap{|uri| uri.host = "::1" }

@stakach
Copy link
Contributor Author

stakach commented Sep 23, 2025

@straight-shoota updated with a new host setter and specs implemented

@stakach stakach requested review from Sija and ysbaddaden September 23, 2025 22:15
@straight-shoota straight-shoota changed the title fix URI when initialized with an IPv6 address Fix URI#host= to wrap IPv6 address in brackets Sep 24, 2025
@ysbaddaden
Copy link
Collaborator

ysbaddaden commented Sep 24, 2025

I looked at how URI in Ruby behaves, and only #hostname= wraps and unwraps IPv6 addresses, while #host= expects IPv6 addresses to be wrapped and raises otherwise. Both also validate the value, so [::z] is invalid for example.

uri.host = "::1"       # => URI::InvalidComponentError
uri.host = "[::1]"     # => hostname=::1 host=[::1]
uri.hostname = "::1"   # => hostname=::1 host=[::1]
uri.hostname = "[::1]" # => hostname=::1 host=[::1]

IMO this behavior makes sense.

@straight-shoota
Copy link
Member

straight-shoota commented Sep 24, 2025

That's a good point. Our hostname getter also unwraps brackets. So moving the wrapping to a new hostname= setter seems to make sense.

Then we'd need to figure out if/how this should be available in the constructor, though. Maybe we could add an overload with a named parameter hostname instead of host?

Raising on invalid values is directly related to #12798. I'd keep that out of this PR.

@stakach
Copy link
Contributor Author

stakach commented Sep 24, 2025

does the behavior make sense?
Looking at the history of the hostname method in ruby, hostname was introduced in 1.9.3 to reduce foot-guns with IPv6 literals without breaking existing code that depended on the raw, bracketed form, when accessing it. Access is the important difference not setting it.

No one who uses the library will care if their IPv6 address is formatted correctly and no one wants to have their app raise an error because they didn't look closely enough at the two ways to set the host component.

Put it this way, all existing apps that don't manually wrap IPv6 addresses will still be broken if an IPv6 address is provided if we raise on unwrapped IPv6 (as per my example code) - just differently broken (breaking change)
However we can fix IPv6 support without breaking existing usage patterns and be better than ruby

@ysbaddaden
Copy link
Collaborator

ysbaddaden commented Sep 25, 2025

Hum, I guess we should have never imported the hostname vs host methods and should have had a single host property whose setter would unwrap (as a convenience), and have the #to_s method wrap the IPv6 address. There's no point to the brackets other than in the URI format AFAICT.

But that would be a breaking change 😭

@ysbaddaden ysbaddaden added this to the 1.18.0 milestone Sep 25, 2025
@Fryguy
Copy link
Contributor

Fryguy commented Sep 25, 2025

Wow this took me back - much nostalgia: https://github.com/ruby/ruby/pull/765

@straight-shoota straight-shoota merged commit 1de6c4f into crystal-lang:master Sep 28, 2025
39 checks passed
@stakach stakach deleted the patch-11 branch September 28, 2025 22:57
@bcardiff
Copy link
Member

I noticed this is breaking a specs in crystal-sqlite3,

https://github.com/crystal-lang/crystal-sqlite3/blob/35127b21c1b31952fa502c328b0fe62386cfd9f3/spec/driver_spec.cr#L13-L14

https://github.com/crystal-lang/crystal-sqlite3/actions/runs/18456712142/job/52579325805

:memory: is now returning the host [:memory:].

Is the right thing to do to update crystal-sqlite3?

@ysbaddaden
Copy link
Collaborator

@bcardiff Good question.

This patch assumes that a host that includes a : char is an IPv6 address, and always wraps the #host property with brackets, then. Since : is a forbidden host code point I suppose it's acceptable.

Maybe use URI#hostname instead of URI#host in sqlite3 so you get the unwrapped :memory:?

@straight-shoota
Copy link
Member

straight-shoota commented Oct 13, 2025

Yeah, //:memory: is not a valid authority.
It's not great that with this change we operate on the invalid state and turn it into something that makes even less sense. But I agree it's acceptable. Passing an invalid host results (currently) in undefined behaviour.

We should try to improve this situation, though. For example we could make the condition more strict to look check that the host value is actually an IPv6 pattern. Or maybe URI.parse should raise on an invalid host. 🤷

@stakach
Copy link
Contributor Author

stakach commented Oct 13, 2025

Switching to #hostname is the answer and the reason that method exists

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants