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

Core: Sync args state to URL #13803

Merged
merged 32 commits into from
Feb 17, 2021
Merged

Core: Sync args state to URL #13803

merged 32 commits into from
Feb 17, 2021

Conversation

ghengeveld
Copy link
Member

Issue: #12291

What I did

This synchronizes args state with the URL.

How to test

  • Is this testable with Jest or Chromatic screenshots? No, but a Cypress test could.
  • Does this need a new example in the kitchen sink apps? No
  • Does this need an update to the documentation? Maybe

If your answer is yes to any of these, please make sure to include it in your PR.

Copy link
Member

@tmeasday tmeasday left a comment

Choose a reason for hiding this comment

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

I'm unsure about this approach.

We need to also parse args from the URL if you browse directly to the /iframe.html (this is what folks want to do in tests after all)

Following through from that, we need to have the arg parsing logic in the preview. Possibly we also should set the iframe URL from the preview too (this part is up for debate).

I sort of feel like the manager should perhaps just pass the arg query parameters straight through to the preview by whacking them on the iframe URL (similar to how it does the story id, IIRC) and let the preview map them to actual arg values and ultimately emit the args, to be later captured by the manager.

OTOH I'm not sure who should then serialize said args to the URL.

addons/controls/src/ControlsPanel.tsx Outdated Show resolved Hide resolved
addons/controls/src/ControlsPanel.tsx Outdated Show resolved Hide resolved
lib/api/src/modules/url.ts Outdated Show resolved Hide resolved
lib/api/src/modules/url.ts Outdated Show resolved Hide resolved
@ghengeveld ghengeveld marked this pull request as draft February 4, 2021 08:46
@j3rem1e
Copy link
Contributor

j3rem1e commented Feb 4, 2021

Imho, this changes will add security issue like XSS.

Some components (if not all) doesn't sanitize its properties, because there are used internally. When using args, the user has to interact with Storybook.

However, if the properties are automatically inject through the url to the component, it's possible to craft an url to abuse components without the user knowledge.

@ghengeveld ghengeveld changed the title Sync args state to URL Core: Sync args state to URL Feb 7, 2021
@ghengeveld
Copy link
Member Author

I'm unsure about this approach.

We need to also parse args from the URL if you browse directly to the /iframe.html (this is what folks want to do in tests after all)

Following through from that, we need to have the arg parsing logic in the preview. Possibly we also should set the iframe URL from the preview too (this part is up for debate).

@tmeasday You're right. My first draft didn't include any handling for the iframe (i.e. support "ejecting"). I've added that now, and that indeed involved parsing the args parameter in the preview instead of in the manager. The preview will now set the initial args based on its URL, rather than the manager. The manager will pick up that args change and update accordingly.

I sort of feel like the manager should perhaps just pass the arg query parameters straight through to the preview by whacking them on the iframe URL (similar to how it does the story id, IIRC) and let the preview map them to actual arg values and ultimately emit the args, to be later captured by the manager.

OTOH I'm not sure who should then serialize said args to the URL.

I don't think we need to do this. When ejecting, the relevant args are added to the iframe URL, so it will parse and use them. When loaded from the manager, the iframe URL already inherits the params from the manager URL, so it will initialize in the same way and send a signal to the manager to update the args based on its URL. When navigating to a different story, the iframe URL will not be updated, but the args are updated by other means (from the manager). The URL is only relevant for the initial page load, not when switching between stories. Of course this means that "open frame in new tab" will not work as expected, but I think this is a reasonable limitation if it means we can avoid additional URL syncing complexity. People should just use the eject (or copy) feature for this purpose.

@ghengeveld
Copy link
Member Author

ghengeveld commented Feb 7, 2021

Imho, this changes will add security issue like XSS.

Some components (if not all) doesn't sanitize its properties, because there are used internally. When using args, the user has to interact with Storybook.

However, if the properties are automatically inject through the url to the component, it's possible to craft an url to abuse components without the user knowledge.

@j3rem1e That's correct. We are not doing any kind of sanitization, and args are often passed directly to a component, which may be vulnerable to XSS (that depends on the details of how those props are used).

Although I think the risk involved is very small (Storybooks typically run on localhost or a unique subdomain, so stealing a cookie isn't really interesting), we should at least offer some way to secure against this. For one, I think we should offer a way to opt out of this feature. Another thing we could do is include a signed hash in the URL, and verify it before applying the URL args. We could also attempt to sanitize the args, but it's very tricky to get that right. It would be the least impactful thing to do though. What do you think?

For some added context: The knobs addon is also vulnerable to this. While it doesn't sync to the URL, it does parse from the URL.

@shilman
Copy link
Member

shilman commented Feb 7, 2021

@j3rem1e can you provide an XSS example, even if it's contrived, to help me understand?

@j3rem1e
Copy link
Contributor

j3rem1e commented Feb 7, 2021

@ghengeveld I work for a company focused on security, so my first reflex is that default should be secure :-)

Let's take a hypothetical example:

I have a Timeline component. This component shows a discussion and each message is generated from markdown in my backend. my backend is secured, the generated html is sanitized on the backend, the canal is https, my Timeline doesn't sanitize a 2nd time the messages. It's an internal component and it's not possible to use it without the backend.

This component is tested through Storybook. As my controls are auto-generated, I see a "message" property which I can enter anything (and html). but it's not important here, because it is entered by the tester and run in the context of the browser of the tester.

With this PR, I can craft an URL which show the timeline with arbitrary html/javascript. I can send this url to anybody, and the script will execute in the context of the target browser.

In my case, Storybook run under a subdomain of my production server (jenkins) next to a wiki which documents internals of the application. jenkins and the wiki are secured, of course. However, If I send the crafted URL to a user who has admin access to the jenkins, and this user is logged into jenkins, then I can probably steal his cookie/create an user/etc.

If I send a "localhost" url to a dev whose is running storybook, then I can execute scripts in the context of "localhost" which is a secure location by default. I can do a lot of things in this context.

@tmeasday
Copy link
Member

tmeasday commented Feb 7, 2021

Another thing we could do is include a signed hash in the URL, and verify it before applying the URL args. We could also attempt to sanitize the args, but it's very tricky to get that right. It would be the least impactful thing to do though. What do you think?

This means URLs are no-longer "hackable" which I think would be a pity. Maybe we could add this signing feature as an opt-in security measure though? Although if the attacker has access to the SB they could easily construct a signed URL containing arbitrary args too, so I'm not sure it achieves all that much.

Probably the right thing is to make the whole feature opt-out (or opt-in).

In my case, Storybook run under a subdomain of my production server (jenkins) next to a wiki which documents internals of the application. jenkins and the wiki are secured, of course. However, If I send the crafted URL to a user who has admin access to the jenkins, and this user is logged into jenkins, then I can probably steal his cookie/create an user/etc.

PS aren't your cookies domain scoped?

Copy link
Member

@tmeasday tmeasday left a comment

Choose a reason for hiding this comment

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

I'd like to see some tests of the initial arg getting set on the preview side:

  1. A unit test in the store when you set the args in selection specifier, probably add here: https://github.com/storybookjs/storybook/blob/3fd8b6b39746ea54a5e43eb19333097aa3cb0dce/lib/client-api/src/story_store.test.ts#L761:L761

  2. A unit test of the url parsing, here:

    describe('getSelectionSpecifierFromPath', () => {

  3. A fuller integration test that the wiring all works (in the preview at least, its a bit tricky to do preview/manager tests) if you can. We have some in here: https://github.com/storybookjs/storybook/blob/next/lib/core/src/client/preview/start.test.ts but it could be tricky.

  4. Potentially a full end-to-end test that one of the example Storybooks
    a) renders properly with the args specified in the URL
    b) the URL changes when you change the arg.

I actually don't even know how to do 4 ( 😉 ) but I reckon this is the kind of feature that could regress really easily as it relies on stuff across the whole stack, so I think its worth the effort to get a test up.

fullAPI.on(SET_STORIES, () => {
const data = fullAPI.getCurrentStoryData();
const storyArgs = isStory(data) ? data.args : undefined;
initialArgs[data.id] = storyArgs;
Copy link
Member

Choose a reason for hiding this comment

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

Doesn't SET_CURRENT_STORY get called for the first story too?

Copy link
Member Author

Choose a reason for hiding this comment

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

No it doesn't. It only triggers when switching between stories.

const data = fullAPI.getCurrentStoryData();
const args = isStory(data) ? data.args : undefined;
initialArgs[data.id] = initialArgs[data.id] || args;
updateArgsParam({ storyId: data.id, args });
Copy link
Member

Choose a reason for hiding this comment

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

Is this line necessary? By definition all the args are "initial", right?

Copy link
Member Author

Choose a reason for hiding this comment

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

This restores any previously customized args when switching back and forth between stories. So no, they're not always initial.

Of course this assumes we want this behavior of restoring args to a previously customized state rather than resetting to the initial args. I think we do.

Comment on lines 164 to 167
const argsString = stringifyArgs(customizedArgs);
const argsParam = argsString.length ? `&args=${argsString}` : '';
queryNavigate(`${fullAPI.getUrlState().path}${argsParam}`, { replace: true });
api.setQueryParams({ args: argsString });
Copy link
Member

Choose a reason for hiding this comment

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

Should we checking something's changed before calling replaceState?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think that's worthwhile. Probably the browser already optimizes for that.

@@ -60,8 +60,16 @@ const deprecatedLegacyQuery = deprecate(
Use \`id=$storyId\` instead.
See https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#new-url-structure`
);
export const getSelectionSpecifierFromPath: () => StoreSelectionSpecifier = () => {

// Lifted from @storybook/router utils
Copy link
Member

Choose a reason for hiding this comment

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

We can't import? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

No, we have no shared code between manager and preview right now (core-events being the sole exception, but thats just a bunch of constants). Norbert and Michael are working on something which will introduced a core-common package, but until then it's easier to copy/paste.

lib/core/src/client/preview/start.ts Outdated Show resolved Hide resolved
@tmeasday
Copy link
Member

tmeasday commented Feb 7, 2021

I jumped the gun here again didn't I @ghengeveld? (Noticed this PR is still a draft). Still hopefully the comments above are helpeful.

Of course this means that "open frame in new tab" will not work as expected, but I think this is a reasonable limitation if it means we can avoid additional URL syncing complexity. People should just use the eject (or copy) feature for this purpose.

Yeah, I'm already not sure how this works. Do we even update the URL with the id when changing story anyway?

Copy link
Member

@shilman shilman left a comment

Choose a reason for hiding this comment

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

Wonderful!!! 😻

Copy link
Contributor

@j3rem1e j3rem1e left a comment

Choose a reason for hiding this comment

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

Looks good to me, it's a great feature.

@ghengeveld
Copy link
Member Author

ghengeveld commented Feb 13, 2021

We're not quite there yet. The recent change to initialize the story args based on the URL in the story store changed the sequence of steps and is causing the URL to get stripped of other args whenever you change one arg.

Copy link
Member

@tmeasday tmeasday left a comment

Choose a reason for hiding this comment

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

Code's looking good to me

The recent change to initialize the story args based on the URL in the story store changed the sequence of steps and is causing the URL to get stripped of other args whenever you change one arg.

Can we encode this problem in a test then? Would be good not to regress later.

I think the unit + integration tests are sufficient to cover all the logic, and if it breaks regardless it'll be pretty apparent during use.

If you are happy the manager-side tests guarantee args will make it to the iframe and the URL will update correctly (perhaps see above), then I think it's good.

@tmeasday
Copy link
Member

The recent change to initialize the story args based on the URL in the story store changed the sequence of steps and is causing the URL to get stripped of other args whenever you change one arg.

Is the problem here that the manager has no way to tell that the args of the story aren't the original args? Perhaps we need to get the timing of things just right so that the SET_STORIES event is emitted with the true args, and then the args change, with a STORY_ARGS_UPDATED before the story renders the first time. WDYT?

Warn when attempting to serialize or unserialize args with disallowed characters.
@ghengeveld
Copy link
Member Author

Did a bunch more work on this. Still need to apply throttling because we're going a lot of work to serialize args.

@ghengeveld
Copy link
Member Author

There's two commits that can be reverted when the next version of qs is released (and our dependency is updated to use it):

Currently, qs does not support the allowSparse flag (it's merged but yet to be released). That means any array values will be synced to the URL as a whole, rather than just the particular values that deviate from their initial value. To keep the URL readable and as short as possible, it's better to use sparse arrays when we can.

Copy link
Member

@shilman shilman left a comment

Choose a reason for hiding this comment

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

Merging!

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.

4 participants