sveltejs / sapper

The next small thing in web development, powered by Svelte
https://sapper.svelte.dev
MIT License
7k stars 434 forks source link

Clarify in the docs that preload cannot guarantee access to either client-side-only or server-side-only information #1134

Closed natevaughan closed 4 years ago

natevaughan commented 4 years ago

Sapper preload has unpredictable behavior

Consider this common web application setup:

[browser]  < — > [api server]
     ^
     |
     v
[frontend server or CDN]

The api server may be running any technology and serving protected resources. The frontend server is Sapper, and is performing dynamic routing and/or server-side rendering, or may be a statically-generated sapper export hosted on a CDN.

In this setup, many use cases are not supported by Sapper preload:

These problems all stem from one fact: Sapper does not guarantee where preload will run. In a simple two-route application (say index.svelte and me.svelte), preload will run in the following places in the following scenarios:

This results following behavior:

This can be a confusing experience that frustrates and alienates devs as svelte begins to gain more attention and adoption.

Exacerbating the problem: solutions for narrow use-cases

Some proposed solutions work if an application happens to use Sapper as both an application's frontend server AND api server for all protected resources:

[browser]
     ^
     |
     v
[frontend server AND api server]

For example this thread https://github.com/sveltejs/sapper/issues/178 discusses and proposing solutions for authentication, but all solutions assume that the Sapper server.js will contain code that sets up and handles authentication and will use e.g. passport.js for configuring authentication.

The solution: Clarify that preload should be used only for server-side rendering of static data and static site generation

If we zoom out from the narrow use-case (where Sapper happens to be all three of the frontend server, api server, and source of authentication), then preload should really only be used for one thing: server-side rendering of truly static data and static site generation.

Other Svelte features are available that are guaranteed to run only on the broswer (e.g. a function returning a promise and the {#await} block) and these should be emphasized for all use cases involving dynamic data from authenticated api servers.

As Svelte and Sapper gain wider adoption (and I hope they do!), this distinction should be very clear in documentation, examples, and tutorials.

Alternatives

This feature is important for Svelte and Sapper to gain momentum

My personal view is that this feature may seem less important now than it will as more users adopt Svelte and Sapper. Most Svelte/Sapper users I've seen (including https://svelte.dev) are using it in the "narrow" use case I've described above.

However, when you look at the broad ecosystem of frontend and backend technologies, integrated node.js frontends and backends are a fraction of all use cases. By focusing on the assumption that a developer is only using node.js, Svelte/Sapper would be sending a signal that "this isn't for you" to users of:

Additional context

Here are some example uses of preload that will fail in various contexts.

1. Fetch protected data

Using credentials for a request to a 3rd party API:

export async function preload(page, session) {
    const res = await this.fetch(
      `https://api.example.com/results/${page.params.id}`,
      {
        credentials: 'include' // fails on server, as there are no credentials to include
      }
    );
    return await res.json();
}

2. Using a cookie value

Use a cookie value previously set:

function getCookie(name) {
    ... // ommitted for brevity
}

export async function preload(page, session) {
    const res = await this.fetch(
      `https://api.example.com/results/${ getCookie("id") }` // fails on server because server cannot access cookies
    );
    return await res.json();
}

3. Using an environment variable secret:

export async function preload(page, session) {
    const res = await this.fetch(
      `https://api.example.com/results/${page.params.id}`,
      {
        headers: {
          "api-key": `${process.env.SECRET_API_KEY` // fails in browser, using sapper-env would expose secret
        }
      }
    );
    return await res.json();
}
antony commented 4 years ago

@natevaughan If you make your cookie httpOnly and secure, and make sure you pass credentials: 'include', the server side will have access to all your cookies too, and everything will work without issue.

This is how https://beyonk.com works, and I've written a library provides an abstraction (so that you can always make the exact same call no matter where you use it) here:

https://www.npmjs.com/package/@beyonk/sapper-httpclient

If this isn't clear from the docs - we would really appreciate a PR to help us make it clear.

natevaughan commented 4 years ago

@antony Thanks for your response. I'll look into the cookie issue. But how would the Sapper server have access to a cookie (e.g. a sessionid) to another server?

antony commented 4 years ago

@natevaughan How this works is:

  1. Your API recevies a login request
  2. Your API returns a correctly set cookie with httpOnly + secure set.
  3. Your browser absorbs the cookie and starts sending it with every request
  4. When you change route, the browser request to the new route contains your cookie (automatically)
  5. Sapper server uses the cookie where it needs to

The added advantage is that the client code never has access to your cookie (httpOnly), purely relying on the browser to transport it.

natevaughan commented 4 years ago

@antony In your example number 4, the browser will only send the coookie if Sapper is the same server as the api. Imagine my api server is hosted at https://myapiserver.com and my frontend server is hosted at https://myfrontend.com. To spell it out more clearly:

  1. https://myapiserver.com recevies a login request
  2. https://myapiserver.com returns a correctly set cookie with httpOnly + secure set (with, say, a session id)
  3. The browser absorbs the cookie and starts sending it with every request if that request is to https://myapiserver.com. This includes Sapper preload requests IF those requests are handled by the browser and directed to https://myapiserver.com.
  4. When you change route, the browser requests the new route from https://myfrontend.com The browser does not send the cookie set by https://myapiserver.com.
  5. Sapper running at https://myfrontend.com does not know the value of the cookie set by https://myapiserver.com, so any preload requests that it executes will fail.

By the way, https://beyonk.com looks well done! What are you using for your backend api server?

antony commented 4 years ago

Gotcha, but this is a limitation (security feature) of cookies, rather than Sapper, so Sapper isn't going to be able to work around this.

Your options are:

  1. Like Beyonk, put your API on a subdomain of your Frontend. Then use a wildcard domain in your cookie.
  2. Like create-react-app and friends, use /api/ on your Sapper server-side to proxy requests to your real API server. now also supports this.

We're using https://hapi.dev/ for our backend - my feeling is that Hapi is to the backend what Svelte is to the frontend :)

natevaughan commented 4 years ago

@antony Thanks for your responses.

"Put your API on a subdomain of your Frontend" would work for most people in most use cases, but it's worth noting that this works differently than client-side SPA frameworks which can make authenticated requests to any api (provided that api's CORS settings allow the request). I suppose the Sapper docs could be updated to specify "only use preload to make authenticated requests when those requests are same-domain." On a related note, it's worth noting that any use of preload requests to authenticated pages with sapper export will render out error pages for that route (!).

Side note: "use /api/ on your Sapper server-side to proxy requests to your real API server" seems like a non-starter for an application of any size. Do people really do this? I personally can't imagine doing so.

Back to setting and reading a cookie value. Could you give me an example of setting and accessing a cookie that works in both the client and server? Here's an example that doesn't work:

cookies.js:

/**
 * Get a cookie
 * Source MDN / W3schools
 * https://www.w3schools.com/js/js_cookies.asp} 
 */
function getCookie(cname) {
    var name = cname + '=';
    var decodedCookie = decodeURIComponent(document.cookie); // document is not defined (when run on the server)
    var ca = decodedCookie.split(';');
    for(var i = 0; i <ca.length; i++) {
        var c = ca[i];
        while (c.charAt(0) == ' ') {
        c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
        return c.substring(name.length, c.length);
        }
    }
    return '';
}

/**
 * Set a cookie
 * Source MDN / W3schools
 * https://www.w3schools.com/js/js_cookies.asp
 */
export function setCookie(cname, cvalue, expiremins) {
    var d = new Date();
    d.setTime(d.getTime() + (expiremins*60*1000));
    var expires = 'expires='+ d.toUTCString();
    document.cookie = cname + '=' + cvalue + ';' + expires + ';path=/';
}

index.svelte:


<script context="module">
import { getCookie } from "cookies.js"
export async function preload() {
  console.log("attempting to return a cookie value.") 
  val cookieVal = getCookie("myCookie") // fails on server
  return { cookieVal };
}

<script>
import { getCookie, setCookie } from "cookies.js"

export let cookieVal;
if (typeof cookieVal === 'undefined) {
  cookieVal = Math.random();
  setCookie("myCookie", cookieVal, 60);
}
</script>

<h1> the cookie is { cookieVal } </h1>
antony commented 4 years ago

It says in the docs that applications which require authentication aren't a good use-case for sapper export.

Your problem stems from the fact that the request from the browser only sends same-domain cookies to the backend, so the backend has no knowledge of the cookies which relate to the other server. This is a scenario that definitely exists, but I'm not sure it's as common as you imagine.

However if this were the case, If I were looking at ways to get around this, I would probably leverage process.browser to determine if preload is running on the browser or server, and then use different ways to authenticate with the API.

In the industry though, I have definitely seen a multitude of cases where /api/ is used as a proxy to a backend, and the fact that this is a default part of the Create React App setup, means that it is likely very common indeed.

I'm building out a full auth example in my spare time, so that people can see how it works, but I essentially use:

h.response().state({ cookieName: cookieValue }) in my API's auth endpoint (and the Hapi cookie config sets httpOnly, and secure).

When the Sapper calls this endpoint via fetch, the cookie is read and stored by the browser.

Then, future API calls from fetch with { crentials: 'include' } will pass that cookie to my API calls and back, from preload, and also in the browser. It's as simple as that. I don't need to read the cookie in the browser at all.

We're verging onto the territory of user support here, so I'm going to close this issue (as I don't think there's anything that Sapper needs to fix here - the constraint is a browser security concern that we simply can't work around), but come and have a chat in https://svelte.dev/chat if you're still struggling.

(btw, I assume you'll find the same constraint in Next and Nuxt, as the architecture is similar).

natevaughan commented 4 years ago

@antony Thanks for your examples and your help. This thread ultimately confirmed a few of my frustrations with Sapper, the (undocumented) assumptions it makes, and its limitations, which has been helpful in its own way. See ya around.

dannydenenberg commented 4 years ago

I agree with everything @natevaughan said. You can't use preload to check/validate cookies because it isn't ensured to be run from the browser. Oy vey...

dannydenenberg commented 4 years ago

Hi all, I found a pretty good workaround. You can consistently access cookies in the async function preload(page, session) by passing cookies into the session manually when setting up the sapper middleware like so:

app.use(
  compression({
    threshold: 0,
  }),
  sirv("static", {
    dev,
  }),
  sapper.middleware({
    session: (req, res) => ({
      cookies: req.cookies,
    }),
  }),
);

Usage

Then, in the preload, access cookies like so: session.cookies.

trixn86 commented 4 years ago

@dannydenenberg

Hi all, I found a pretty good workaround. You can consistently access cookies in the async function preload(page, session) by passing cookies into the session manually when setting up the sapper middleware like so:

I thought about that way to authenticate my API requests on the sapper server-side too. But isn't that exposing the cookie to client-side javascript making it vulnerable to a potential XSS attacks? As far as I understood it you can access the session in the client using:

import { stores } from '@sapper/app';
const { session } = stores();

// this could be accessed by any javascript running in the client and is basically breaking the security of a httpOnly cookie.
console.log(session.cookies);

Correct me if my assumptions are wrong.