Skip to content
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

Multiple fallback URLs and interaction with CORS #76

Closed
bicknellr opened this issue Nov 15, 2018 · 10 comments
Closed

Multiple fallback URLs and interaction with CORS #76

bicknellr opened this issue Nov 15, 2018 · 10 comments

Comments

@bicknellr
Copy link

@hiroshige-g found these:

The crossorigin attribute allows the user to control how a request is made and how it is interpreted when received. When paired with multiple URL fallbacks, if CORS failures are considered a condition for falling back to the next URL, then it is not true that a single import: URL - even within the same scope - maps to a single, unique network URL.

For example, given this HTML:

<img id="A" src="import:some-image">
<img id="B" src="import:some-image" crossorigin>

If the import map controlling the scope that these <img>s both use for mapping the src attribute specifies that import:some-image should map to these network URLs: [https://example.com/image-a.jpg, https://example.com/image-b.jpg], and https://example.com/image-a.jpg does not have a compatible value for Access-Control-Allow-Origin, then #A would result in import:some-image mapping to https://example.com/image-a.jpg and #B would result in import:some-image mapping to https://example.com/image-b.jpg.


Multiple fallback URLs and CORS also means it's not possible to implement an "import.resolve" API that allows the user to map import: URLs to network URLs themselves because the CORS issue described above means that the context in which a particular URL would be used determines whether or not fallback would happen.

@guybedford
Copy link
Collaborator

Not treating CORS failures as fallbacks seems an adequate solution this. Or are there other cases or factors that might still come into play?

@bicknellr
Copy link
Author

bicknellr commented Nov 16, 2018

I think you're right that if CORS isn't considered when deciding to go to the next fallback option (i.e. making it so that you try the next fallback if and only if its an actual network failure) then that handles the uniqueness problem.

Hopefully I'm remembering this correctly, but I think one specific concern that @hiroshige-g had about this was that it might let you distinguish between when the resource failed specifically because of CORS. If this provides any way for users to resolve import: URLs to real network URLs at some point - e.g. an "import.resolve", which I expect will be necessary (#77 #75) - then I think it's possible to determine whether or not the page can contact a resource rather than just not having CORS access:

If you had this import map:

{
  "imports": {
    "name": ["http://vulnerable-host/", "http://cors-leak-sentinel.com/"],
  }
}

... then you could determine whether or not https://vulnerable-host/something failed because of a CORS error or otherwise by trying to resolve import:name/something with import.resolve and then passing the resulting network URL to something that's sensitive to the crossorigin attribute:

  • If the URL given by import.resolve is cors-leak-sentinel.com, then contacting http://vulnerable-host/something failed for non-CORS reasons.
  • If the URL did resolve to http://vulnerable-host/something, then you can pass the URL to the CORS-sensitive thing to determine whether or not you have CORS access to it. (e.g. fetch)

Basically, is it dangerous to allow users to know whether or not CORS is the reason they can't access something? If so, does that mean that import maps has a mutually-exclusive choice between allowing multiple URLs in the fallback list and allowing the user to know the resolved network URL of an import: URL? (i.e. import maps can't allow both multiple fallback URLs and an import.resolve implementation)

@hiroshige-g
Copy link
Collaborator

hiroshige-g commented Nov 20, 2018

Option 0. We fallback if fetch fails, including CORS failures. (The original issue #76 (comment); The current proto-spec?)

  • Import Maps resolution is context-dependent.
  • Particularly, there is no single context-independent answer what import.resolve should return.

If we don't treat CORS failures as fallbacks for <img id="B" src="import:some-image" crossorigin>,

Option 1. We fallback if fetch("https://example.com/image-a.jpg", {mode: "no-cors"}) fails.

In this case there are no information leak, as no-cors Fetch API request is already exposed to JavaScript.
However,

  • In Import Map resolution, a no-cors request to https://example.com/image-a.jpg is sent => successful.
    • Resolve import:some-image to https://example.com/image-a.jpg.
  • For main loading of <img>, a cors request to https://example.com/image-a.jpg is sent => failed.

Therefore:

  • The import map resolution result isn't consistent with the <img> element's result.
  • One additional network request to image-a.jpg is made only for import map resolution.
    • If the main loading is a no-cors request e.g. <img id="A" src="import:some-image">, we might want to merge the two requests, but it is hard at least in Chromium implementation.

Option 2. We send a (cors) main request to https://example.com/image-a.jpg and if it fails due to non-CORS reasons => fallback, or otherwise use the response for <img>.

In this case we don't need the additional request.

However,

  • We might leak internals states of Fetch which might cause security issues. I don't came up with specific examples, but Fetch spec carefully choose what to expose and what not to, and adding a new path for exposing something for Import Maps might complicate Fetch spec and implementation.
  • Still the import map resolution result isn't consistent with the <img> element's result.

@hiroshige-g
Copy link
Collaborator

In the previous offline discussion with @bicknellr, I was thinking of Option 0/2 but missed Option 1.
Similar information exposed for http://vulnerable-host/ in #76 (comment) can be exposed by Option 1 safely, but still I think we shouldn't choose Option 2 to avoid expose details of fetch failures unexpectedly. @yutakahirano FYI.

@hiroshige-g
Copy link
Collaborator

Even without import.resolve, JavaScript can check to which URL an import: URL is resolved (e.g. by detecting whether the script at the second URL is executed or not), so the security concern in Option 2 would be still valid without import.resolve.
Also, even without import.resolve, Option 0 (context-dependent Import Map resolution) might still look conceptually confusing.

@guybedford
Copy link
Collaborator

Basically, is it dangerous to allow users to know whether or not CORS is the reason they can't access something?

As far as I'm aware, CORS errors are already detectable because they return a status code 0 and empty body right? That is, the security risk mitigation of CORS is spilling any header or response information itself from the origin, not in the information of whether or not there was a CORS failure. You can know you've been blocked by CORS through the status code 0, but you can't get any more specific information than that. Please correct me if I'm wrong here though.

Still the import map resolution result isn't consistent with the element's result.

@hiroshige-g could you clarify this inconsistency further? If the import map resolves to image-a.jpg despite pending CORS failure, then the load of the first fails, while the load of the second succeeds, but that seems fine to me?

Also, when you mention these fetch requests in the options, are you considering these requests to be like "preflight" requests used only for resolution? Am I right in thinking that all options you describe effectively make this request to resolve the import map before passing the resolved URL down further?

If that is the case I'd be somewhat concerned about the performance of this approach in incurring latency for every package fallback resolution. I tend to think aiming for a lower-level solution to this closer to the connection level when handling lots of fallbacks would be useful to avoid this.

@domenic
Copy link
Collaborator

domenic commented Nov 20, 2018

I haven't read this large thread all the way through yet, but in case it hasn't been mentioned, I was thinking one solution in this problem space is to make all import: URL-initiated fetches use CORS. Apologies if that's already been discussed; I will try to read more later.

@hiroshige-g
Copy link
Collaborator

@domenic (#76 (comment))

Essentially any difference (not only CORS) in fetch's options between request can cause the same issue. So for example even we enforce CORS, still we can trigger this issue by e.g. differences in credentials mode, like:

<img id="A" src="import:some-image" crossorigin="anonymous">
<img id="B" src="import:some-image" crossorigin="use-credentials">

@hiroshige-g
Copy link
Collaborator

As far as I'm aware, CORS errors are already detectable because they return a status code 0 and empty body right?

There are no ways to distinguish whether the status code 0 (in XHRs) is caused by CORS errors or by other kinds of network failures from a single CORS request/response.
Anyway CORS errors can be (mostly) detected if we do another request by fetch(url, {mode: 'no-cors'}) and to see whether it succeeds or not, so my concern is moving from exposing CORS errors to more subtle/potential things, though (sorry for confusion).

If the import map resolves to image-a.jpg despite pending CORS failure, then the load of the first fails, while the load of the second succeeds, but that seems fine to me?

I felt this is inconsistent (import map resolution succeeded and resolving to image-a.jpg vs. <img> load fails for image-a.jpg), but this might be a matter of taste.
If the fallback is meant to be used for "fallback if network/server is down" (i.e. perhaps "connection level" you mention), then this might be fine.

Also, when you mention these fetch requests in the options, are you considering these requests to be like "preflight" requests used only for resolution?
Am I right in thinking that all options you describe effectively make this request to resolve the import map before passing the resolved URL down further?

Yes for Option 1.
We do the first import-map-resolution-only request (and discard it), and then do the main request.

For Options 0 and 2, we do only a single import-map-resolution==main request if it succeeds.

If that is the case I'd be somewhat concerned about the performance of this approach in incurring latency for every package fallback resolution.

Agree.

I tend to think aiming for a lower-level solution to this closer to the connection level when handling lots of fallbacks would be useful to avoid this.

Is this something like the following?

Option 3. We connect to example.com:443 and we successfully connected, then all URLs under https://example.com/ origin in the Import Map is considered as successful (i.e. not causing fallback).

Pros:

  • Only one additional request per origin.
  • Clearer semantics (import map is clearly connection-based and isn't affected by CORS).

Cons:

  • Security? Can be exploited for port scan: by observing whether fallback occurs, attackers can know whether the specified port is open for TCP connection. This information is not exposed for existing Fetch API/XHRs.

@domenic
Copy link
Collaborator

domenic commented Nov 26, 2018

OK, I've read through this thread now. Thanks very much for folks thinking through these details.

@hiroshige-g

#76 (comment)

Thanks for explaining. I think there are broadly two solutions here. I am not sure how they map to your options 0-3.

Be consistent with <script type=module>

Currently, if you have code like

<script type="module" id="A" src="./foo.mjs" crossorigin="anonymous"></script>
<script type="module" id="B" src="./foo.mjs" crossorigin="use-credentials"></script>

we will fulfill both module graphs with the crossorigin="anonymous" version. This is because the module map is only keyed by URL.

In this world, I think it is fine to say that the answer to your #76 (comment) is to use whatever is encountered first, even for <img>s. Basically, using import: URLs switches you to "treat it like a module" mode.

Key resolution off more than just the URL

The things that would vary most easily would be credentials mode and referrer policy. Also mode, if we don't decide to make it always-CORS. And destination, I suppose, since you can use <img> or <script> or <audio> or ...

We could make resolution dependent on all these inputs. So in #76 (comment) we would not reuse the first resolution for import:some-image; we would end up doing two separate fetches, which may result in different fallbacks.

We could optionally extend this treatment to <script type="module"> as well. This would basically change the module map keys from URLs to requests, I think.

Side note: service worker caches

As far as I can tell, the Cache API (often used in service workers) only matches based on URL and some stuff around the Vary header: https://w3c.github.io/ServiceWorker/#request-matches-cached-item . So that is closer to the script type=module behavior. This is surprising to me, and it's possible I'm misreading the spec.

@domenic domenic added this to the Full featured milestone Jul 3, 2019
@domenic domenic modified the milestones: Full featured, Fallback support Sep 23, 2019
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

No branches or pull requests

4 participants