diff --git a/.github/workflows/deploy-storybook-prod.yml b/.github/workflows/deploy-storybook-prod.yml new file mode 100644 index 0000000000..5894323f9d --- /dev/null +++ b/.github/workflows/deploy-storybook-prod.yml @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +name: Deploy storybook to production + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + if: github.repository == 'microsoftgraph/microsoft-graph-toolkit' + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + registry-url: 'https://registry.npmjs.org' + + - name: Build 🛠 + run: | + npm install -g yarn lerna + yarn + yarn build + yarn storybook:build + + - name: Deploy mgt.dev 🚀 + uses: JamesIves/github-pages-deploy-action@v4.4.1 + with: + branch: gh-pages + folder: storybook-static + target-folder: . + clean-exclude: next + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.storybook/addons/codeEditorAddon/codeAddon.js b/.storybook/addons/codeEditorAddon/codeAddon.js index 06eab32b93..44b8366c6a 100644 --- a/.storybook/addons/codeEditorAddon/codeAddon.js +++ b/.storybook/addons/codeEditorAddon/codeAddon.js @@ -4,6 +4,7 @@ import { ProviderState } from '../../../packages/mgt-element/dist/es6/providers/ import { EditorElement } from './editor'; import { CLIENTID, SETPROVIDER_EVENT, AUTH_PAGE } from '../../env'; import { beautifyContent } from '../../utils/beautifyContent'; +import { isValidManifestUrl } from '../../utils/isValidManifestUrl'; const mgtScriptName = './mgt.storybook.js'; @@ -140,9 +141,7 @@ export const withCodeEditor = makeDecorator({ } }; - const isValid = manifestUrl => { - return manifestUrl && manifestUrl.startsWith('https://raw.githubusercontent.com/pnp/mgt-samples/main/'); - }; + const isValid = isValidManifestUrl; if (context.name === 'Editor') { // If the editor is not iframed (Docs, GE, etc.) @@ -152,6 +151,11 @@ export const withCodeEditor = makeDecorator({ if (isValid(manifestUrl)) { getContent(manifestUrl, true).then(manifest => { + const contentUrls = [manifest[0].preview.html, manifest[0].preview.js, manifest[0].preview.css]; + if (contentUrls.some(u => u && !isValid(u))) { + console.warn(`🦒: Manifest contains untrusted URLs`); + return; + } Promise.all([ getContent(manifest[0].preview.html), getContent(manifest[0].preview.js), diff --git a/.storybook/utils/isValidManifestUrl.js b/.storybook/utils/isValidManifestUrl.js new file mode 100644 index 0000000000..51f661c74e --- /dev/null +++ b/.storybook/utils/isValidManifestUrl.js @@ -0,0 +1,20 @@ +const TRUSTED_PREFIX = 'https://raw.githubusercontent.com/pnp/mgt-samples/main/'; + +/** + * Validates that a URL points to a trusted location within the pnp/mgt-samples repository. + * Uses URL normalization to prevent path traversal attacks (e.g., '../' segments). + * + * @param {string} url - The URL to validate + * @returns {boolean} Whether the URL is trusted + */ +export const isValidManifestUrl = url => { + if (!url) return false; + try { + const normalized = new URL(url).href; + return normalized.startsWith(TRUSTED_PREFIX) && !normalized.includes('..'); + } catch { + return false; + } +}; + +export { TRUSTED_PREFIX }; diff --git a/.storybook/utils/isValidManifestUrl.tests.js b/.storybook/utils/isValidManifestUrl.tests.js new file mode 100644 index 0000000000..8cf10ac5ae --- /dev/null +++ b/.storybook/utils/isValidManifestUrl.tests.js @@ -0,0 +1,92 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isValidManifestUrl, TRUSTED_PREFIX } from './isValidManifestUrl.js'; + +describe('isValidManifestUrl', () => { + describe('valid URLs', () => { + it('should accept a URL directly under the trusted prefix', () => { + const url = `${TRUSTED_PREFIX}samples/my-sample/manifest.json`; + assert.equal(isValidManifestUrl(url), true); + }); + + it('should accept a URL in a nested path under the trusted prefix', () => { + const url = `${TRUSTED_PREFIX}samples/app/deep/path/manifest.json`; + assert.equal(isValidManifestUrl(url), true); + }); + + it('should accept the trusted prefix itself', () => { + assert.equal(isValidManifestUrl(TRUSTED_PREFIX), true); + }); + }); + + describe('path traversal attacks', () => { + it('should reject a URL with ../ that traverses to another repository', () => { + const url = 'https://raw.githubusercontent.com/pnp/mgt-samples/main/../../../attacker/repo/main/manifest.json'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a URL with encoded traversal segments (%2e%2e)', () => { + const url = 'https://raw.githubusercontent.com/pnp/mgt-samples/main/%2e%2e/%2e%2e/%2e%2e/attacker/repo/main/evil.js'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a URL that uses ../ to escape to a sibling repo', () => { + const url = 'https://raw.githubusercontent.com/pnp/mgt-samples/main/../other-repo/main/payload.json'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a URL with multiple ../ segments', () => { + const url = 'https://raw.githubusercontent.com/pnp/mgt-samples/main/../../evil-org/evil-repo/main/manifest.json'; + assert.equal(isValidManifestUrl(url), false); + }); + }); + + describe('different origins and invalid URLs', () => { + it('should reject a URL from a completely different domain', () => { + const url = 'https://evil.com/pnp/mgt-samples/main/manifest.json'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a URL from a different GitHub user/org', () => { + const url = 'https://raw.githubusercontent.com/attacker/malicious-repo/main/manifest.json'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a URL that only partially matches the prefix', () => { + const url = 'https://raw.githubusercontent.com/pnp/mgt-samples-evil/main/manifest.json'; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a data: URI', () => { + const url = 'data:application/json;base64,W3sicHJldmlldyI6eyJqcyI6Imh0dHA6Ly9ldmlsLmNvbS9ldmlsLmpzIn19XQ=='; + assert.equal(isValidManifestUrl(url), false); + }); + + it('should reject a javascript: URI', () => { + const url = 'javascript:alert(1)'; + assert.equal(isValidManifestUrl(url), false); + }); + }); + + describe('null/empty/malformed inputs', () => { + it('should reject null', () => { + assert.equal(isValidManifestUrl(null), false); + }); + + it('should reject undefined', () => { + assert.equal(isValidManifestUrl(undefined), false); + }); + + it('should reject empty string', () => { + assert.equal(isValidManifestUrl(''), false); + }); + + it('should reject a malformed URL', () => { + assert.equal(isValidManifestUrl('not-a-url'), false); + }); + + it('should reject a relative path', () => { + assert.equal(isValidManifestUrl('../../../etc/passwd'), false); + }); + }); +});