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
43 changes: 43 additions & 0 deletions .github/workflows/deploy-storybook-prod.yml
Original file line number Diff line number Diff line change
@@ -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 }}
10 changes: 7 additions & 3 deletions .storybook/addons/codeEditorAddon/codeAddon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.)
Expand All @@ -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),
Expand Down
20 changes: 20 additions & 0 deletions .storybook/utils/isValidManifestUrl.js
Original file line number Diff line number Diff line change
@@ -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 };
92 changes: 92 additions & 0 deletions .storybook/utils/isValidManifestUrl.tests.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading