Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 173 additions & 3 deletions MIGRATION.md

Large diffs are not rendered by default.

29 changes: 20 additions & 9 deletions code/addons/a11y/src/components/A11yContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
} from 'storybook/internal/core-events';

import type { ClickEventDetails, HighlightMenuItem } from 'storybook/highlight';
import { HIGHLIGHT, REMOVE_HIGHLIGHT, SCROLL_INTO_VIEW } from 'storybook/highlight';
import {
HIGHLIGHT,
HIGHLIGHT_MENU_CLICK,
REMOVE_HIGHLIGHT,
SCROLL_INTO_VIEW,
} from 'storybook/highlight';
import {
experimental_getStatusStore,
experimental_useStatusStore,
Expand Down Expand Up @@ -48,6 +53,9 @@ import type { TestDiscrepancy } from './TestDiscrepancyMessage.tsx';
// They may still appear in the results and be selectable though.
const unhighlightedSelectors = ['html', 'body', 'main'];

const SELECTED_HIGHLIGHT_ID = `${ADDON_ID}/selected`;
const OTHERS_HIGHLIGHT_ID = `${ADDON_ID}/others`;

export interface A11yContextStore {
parameters: A11yParameters;
results: EnhancedResults | undefined;
Expand Down Expand Up @@ -253,7 +261,10 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
);

const handleSelect = useCallback(
(itemId: string, details: ClickEventDetails) => {
(highlightId: string, itemId: string, details: ClickEventDetails) => {
if (highlightId !== SELECTED_HIGHLIGHT_ID && highlightId !== OTHERS_HIGHLIGHT_ID) {
return;
}
const [type, id] = itemId.split('.');
const { helpUrl, nodes } = results?.[type as RuleType]?.find((r) => r.id === id) || {};
const openedWindow = helpUrl && window.open(helpUrl, '_blank', 'noopener,noreferrer');
Expand Down Expand Up @@ -306,7 +317,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
{
[EVENTS.RESULT]: handleResult,
[EVENTS.ERROR]: handleError,
[EVENTS.SELECT]: handleSelect,
[HIGHLIGHT_MENU_CLICK]: handleSelect,
[STORY_CHANGED]: () => setSelectedItems(new Map()),
[STORY_RENDER_PHASE_CHANGED]: handleReset,
[STORY_FINISHED]: handleReport,
Expand Down Expand Up @@ -359,8 +370,8 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
}, [a11ySelection]);

useEffect(() => {
emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/selected`);
emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/others`);
emit(REMOVE_HIGHLIGHT, SELECTED_HIGHLIGHT_ID);
emit(REMOVE_HIGHLIGHT, OTHERS_HIGHLIGHT_ID);

if (!ui.highlighted || isInitial) {
return;
Expand All @@ -377,7 +388,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
});
if (selected.length) {
emit(HIGHLIGHT, {
id: `${ADDON_ID}/selected`,
id: SELECTED_HIGHLIGHT_ID,
priority: 1,
selectors: selected,
styles: {
Expand Down Expand Up @@ -407,7 +418,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
iconLeft: 'info',
iconRight: 'shareAlt',
title: 'Learn how to resolve this violation',
clickEvent: EVENTS.SELECT,
clickable: true,
selectors,
},
];
Expand All @@ -420,7 +431,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
.filter((e) => ![...unhighlightedSelectors, ...selected].includes(e));
if (others?.length) {
emit(HIGHLIGHT, {
id: `${ADDON_ID}/others`,
id: OTHERS_HIGHLIGHT_ID,
selectors: others,
styles: {
outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`,
Expand Down Expand Up @@ -449,7 +460,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
iconLeft: 'info',
iconRight: 'shareAlt',
title: 'Learn how to resolve this violation',
clickEvent: EVENTS.SELECT,
clickable: true,
selectors,
},
];
Expand Down
3 changes: 1 addition & 2 deletions code/addons/a11y/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ const REQUEST = `${ADDON_ID}/request`;
const RUNNING = `${ADDON_ID}/running`;
const ERROR = `${ADDON_ID}/error`;
const MANUAL = `${ADDON_ID}/manual`;
const SELECT = `${ADDON_ID}/select`;

export const DOCUMENTATION_LINK = 'writing-tests/accessibility-testing';
export const DOCUMENTATION_DISCREPANCY_LINK = `${DOCUMENTATION_LINK}#why-are-my-tests-failing-in-different-environments`;

export const TEST_PROVIDER_ID = 'storybook/addon-a11y/test-provider';

export const EVENTS = { RESULT, REQUEST, RUNNING, ERROR, MANUAL, SELECT };
export const EVENTS = { RESULT, REQUEST, RUNNING, ERROR, MANUAL };

export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test';
export const STATUS_TYPE_ID_A11Y = 'storybook/a11y';
206 changes: 206 additions & 0 deletions code/addons/mcp/src/preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,212 @@ describe('experimental_devServer', () => {
expect(result).toBe(mockApp);
});

describe('origin validation', () => {
const initializeRequest = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
});

const createRequest = (headers: Record<string, string>) =>
({
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
host: 'localhost:6006',
...headers,
},
socket: { encrypted: false },
url: '/mcp',
[Symbol.asyncIterator]: async function* () {
yield Buffer.from(initializeRequest);
},
}) as any;

const createResponse = () =>
({
writeHead: vi.fn(),
write: vi.fn(),
end: vi.fn(),
setHeader: vi.fn(),
statusCode: 0,
}) as any;

const createOptionsWithCore = (core: Record<string, unknown>) =>
({
...mockOptions,
presets: {
apply: vi.fn((key: string) => {
if (key === 'core') {
return Promise.resolve(core);
}
if (key === 'features') {
return Promise.resolve({ componentsManifest: false });
}
return Promise.resolve(undefined);
}),
},
}) as unknown as Options;

it('rejects a POST whose Origin is not an allowed host', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest({ origin: 'http://malicious-site.com' }), res);

expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' });
expect(res.end).toHaveBeenCalledWith('Invalid origin');
expect(handler).not.toHaveBeenCalled();
});

it('rejects a non-HTML GET whose Origin is not an allowed host', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
const handlers: Record<string, any> = {};
mockApp.get = vi.fn((path: string, routeHandler: any) => {
handlers[path] = routeHandler;
});
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await handlers['/mcp'](createRequest({ origin: 'http://malicious-site.com' }), res);

expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' });
expect(handler).not.toHaveBeenCalled();
});

it('rejects a POST whose Origin is not a URL', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest({ origin: 'null' }), res);

expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' });
expect(handler).not.toHaveBeenCalled();
});

it('rejects a POST whose Origin is an IP address that is not a known address', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest({ origin: 'http://203.0.113.5' }), res);

expect(res.writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'text/plain' });
expect(handler).not.toHaveBeenCalled();
});

it('accepts a POST without an Origin header', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest({}), res);

expect(res.writeHead).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
});

it('accepts a POST from a localhost Origin', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest({ origin: 'http://localhost:6006' }), res);

expect(res.writeHead).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
});

it('accepts a POST from an Origin listed in core.allowedHosts', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(
mockApp,
createOptionsWithCore({ allowedHosts: ['my-app.example.com'] })
);
const res = createResponse();

await mcpHandler(createRequest({ origin: 'https://my-app.example.com' }), res);

expect(res.writeHead).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
});

it('accepts any Origin when core.allowedHosts is true', async () => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, createOptionsWithCore({ allowedHosts: true }));
const res = createResponse();

await mcpHandler(createRequest({ origin: 'http://malicious-site.com' }), res);

expect(res.writeHead).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
});
});

describe('media type validation', () => {
const createRequest = (contentType: string | undefined) =>
({
method: 'POST',
headers: {
accept: 'application/json',
host: 'localhost:6006',
...(contentType === undefined ? {} : { 'content-type': contentType }),
},
socket: { encrypted: false },
url: '/mcp',
[Symbol.asyncIterator]: async function* () {
yield Buffer.from('{}');
},
}) as any;

const createResponse = () =>
({
writeHead: vi.fn(),
write: vi.fn(),
end: vi.fn(),
setHeader: vi.fn(),
statusCode: 0,
}) as any;

it.each([
undefined,
'text/plain',
'text/plain; x=application/json',
'application/x-www-form-urlencoded',
])('rejects a POST whose Content-Type is %s', async (contentType) => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest(contentType), res);

expect(res.writeHead).toHaveBeenCalledWith(415, { 'Content-Type': 'text/plain' });
expect(handler).not.toHaveBeenCalled();
});

it.each(['application/json', 'application/json; charset=utf-8', 'Application/JSON'])(
'accepts a POST whose Content-Type is %s',
async (contentType) => {
const handler = vi.spyOn(mcpHandlerModule, 'mcpServerHandler').mockResolvedValue(undefined);
await (experimental_devServer as any)(mockApp, mockOptions);
const res = createResponse();

await mcpHandler(createRequest(contentType), res);

expect(res.writeHead).not.toHaveBeenCalled();
expect(handler).toHaveBeenCalledOnce();
}
);
});

it('should handle partial toolsets configuration', async () => {
const partialOptions = {
presets: {
Expand Down
Loading
Loading