-
Notifications
You must be signed in to change notification settings - Fork 340
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix some WPT url tests failing due to invalid surrogates in UTF8 #3554
base: main
Are you sure you want to change the base?
Conversation
npaun
commented
Feb 14, 2025
- Adds jsg::USVString, which is guaranteed not to contain invalid characters in UTF8. This type is intended to make it convenient and consistent to implement Web APIs which say they take a USVString without having to deal with this issue every time.
- Makes jsg::Dict use a HashMap during construction because it's possible that two JS strings will become the same USVString. Last value wins.
kj::Maybe<v8::Local<v8::Object>> parentObject) { | ||
v8::Local<v8::String> str = check(handle->ToString(context)); | ||
v8::Isolate* isolate = context->GetIsolate(); | ||
auto buf = kj::heapArray<char>(str->Utf8LengthV2(isolate) + 1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
technically we can check for str->ContainsOneByteOnly() and omit utf8 calls for one byte strings
@anonrig Some APIs specify that a value is a DOMString which allows those invalid characters. |
I'm thinking out loud: Maybe we can tackle the problem from a different perspective. Rather than allowing invalid characters for all inputs, we can just disallow by default, and introduce DOMString() rather than USVString(). Allowing invalid utf8 values by default can cause security issues. |
kj::String accepts whatever you give it. It contains an array of chars. By convention it is supposed to contain UTF-8 chars, but it is not kj::String's job to validate this. Validating in kj::String would be a bad idea because:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like this is one of those things that nobody cares about except for some pedantic test-writers. Do we really have to do this? What if we just didn't?
kj::Array<kj::ArrayPtr<const char>> getAll(kj::String name); | ||
bool has(jsg::Lock& js, kj::String name, jsg::Optional<kj::String> value); | ||
void set(kj::String name, kj::String value); | ||
void append(jsg::USVString name, jsg::USVString value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this a breaking change? Don't we need a compat flag?
Maybe we could even have a compat flag that enables the strict checks, and just never even bother turning it on by default... but use it for the tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm guessing this isn't the only API where we are technically supposed to be using USVString
. It'd probably be best if we had a single compat flag that enables the checks everywhere, and we don't consider turning it on by default until we've actually audited all our APIs to make sure they are using USVString
if they are supposed to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're going to do that it might be easier to have a compatibility flag that updates the kj::String
type mapping in general to always perform the invalid character replacement. I think the only web platform APIs we have that would not be USVString are the Header
and we already have jsg::ByteString
to cover that case. Either way, I agree that this likely does need a compat flag.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, if we change it everywhere, then cases where an API round-trips text back to JavaScript now have a side effect that might be unexpected...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isn't that already the case when unpaired surrogates are used tho? For instance, if I have a test like...
struct StringsContext: public ContextGlobalObject {
kj::String str(kj::String value) {
return kj::mv(value);
}
JSG_RESOURCE_TYPE(StringsContext) {
JSG_METHOD(str);
}
};
JSG_DECLARE_ISOLATE_TYPE(StringsIsolate, StringsContext);
KJ_TEST("...") {
Evaluator<StringsContext, StringsIsolate> e(v8System);
e.expectEval("const m = '\\ud83c'; str(m) === m", "boolean", "false");
}
The kj::String
is not going to roundtrip successfully since while the unpaired surrogate will not be replaced with the FFFD replacement character on the inbound type mapping, it is replaced on the outbound type mapping.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is replaced on the outbound type mapping.
WTF!
I totally assumed that if V8 was happy to produce WTF-8, it would also accept it, but apparently not. Apparently V8's UTF-8 parser, when presented with a UTF-8 sequence representing an unpaired surrogate -- which is a three-byte sequence -- just replaces each of the three bytes with a replacement character?!?!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well I guess that means no one is relying on us round-tripping unpaired surrogates after all. But why the heck does V8 only do this validation in one direction and not the other?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yikes. The inbound case is a mess.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So how about this as a path forward.... Let's
- introduce a compat flag to treat
kj::String
inbound as USVString (replacing invalid sequences). - introduce
jsg::UsvString
as a more explicit indicator to use replacement that works with or without the compat flag - introduce
jsg::DomString
as an explicit indicator to use the current inbound type mapping and we can find a way to ensure that it works as expected on the outbound too (that is, that it produces WTF and round trips correctly... this might require a v8 patch). - Incrementally move our existing direct uses of
kj::String
over to either one of these explicit string types.
I think most of our APIs are going to handle the switch to USVString semantics easily. It's more likely that we have a far smaller set of APIs that may rely on the current behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMO feels like a lot of work to solve a problem that no one is actually hitting. But I won't stop you.
@kentonv The main motivation I had for fixing this is that the underlying library we use for URL stuff, ada-url, says the caller is responsible for never passing invalid UTF-8 characters. I feel like this test is pretty pedantic but it does seem a bit unpleasant that we'd parse a URL differently than a browser or Node. |
I think we need to distinguish between the specific case of invalid surrogate pairs, vs. lower-level invalid UTF-8 byte sequences. As it stands today, JavaScript strings are UTF-16 -- or more accurately WTF-16, in that they may have unpaired surrogates. We convert them to UTF-8 ourselves; we don't accept UTF-8 from the application. So the only way that our UTF-8 might be "invalid" is specifically unpaired surrogates. Does ada-url barf on unpaired surrogates? Or does it just treat them like any regular 16-bit code point? I would expect the latter, in which case there's no real problem here? |
kj::Maybe<kj::ArrayPtr<const char>> get(jsg::USVString name); | ||
kj::Array<kj::ArrayPtr<const char>> getAll(jsg::USVString name); | ||
bool has(jsg::Lock& js, jsg::USVString name, jsg::Optional<jsg::USVString> value); | ||
void set(jsg::USVString name, jsg::USVString value); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note: you'll likely need to also update the type generation code to handle USVString
Couldn't we have a variation of the
(the "WellFormed" terminology is borrowed by the recent API additions in JS to accomplish the same) |
Side note... stuff like this is why Node.js' file system apis accept string or Buffer for filenames. Years ago I had a consulting client in Japan who was hitting up against the fact that their filesystem contained files and directory names that used multiple text encodings (since on some OS's file paths are just byte sequences). Node.js originally treated all file paths as UTF8. Sometimes we do have to just let invalid sequences be invalid sequences without validation or replacement |
d4b07f6
to
1aae6a6
Compare
... |
1aae6a6
to
9fd4c88
Compare
Hey guys, here's an update on this PR:
I propose we merge this PR at this point and follow-on with further PRs. Here's what's left to discuss and implement:
|