diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd7d262..8f660df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,6 +11,10 @@ on: release: types: [published] +# Default permissions for all jobs (principle of least privilege) +permissions: + contents: read + jobs: build-styles: name: SCSS → CSS Compilation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31da6a3..2fcd46d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: pull_request: branches: [ main ] +# Default permissions for all jobs (principle of least privilege) +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7e2dfcb..e6777d5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,6 +5,12 @@ on: branches: - main # Deploy on pushes to the main branch +# Permissions for GitHub Pages deployment +permissions: + contents: write # Required for gh-deploy to push to gh-pages branch + pages: write # Required for GitHub Pages deployment + id-token: write # Required for GitHub Pages deployment + jobs: deploy: runs-on: ubuntu-latest diff --git a/.github/workflows/rsr-compliance.yml b/.github/workflows/rsr-compliance.yml index b89247e..21eee58 100644 --- a/.github/workflows/rsr-compliance.yml +++ b/.github/workflows/rsr-compliance.yml @@ -9,6 +9,10 @@ on: push: branches: [ main ] +# Default permissions for all jobs (principle of least privilege) +permissions: + contents: read + jobs: rsr-core-check: name: RSR Core Files Check diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index fa9ca8a..f7a56b6 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -12,6 +12,10 @@ on: # Run daily at 02:00 UTC - cron: '0 2 * * *' +# Default permissions for all jobs (principle of least privilege) +permissions: + contents: read + jobs: npm-audit: name: NPM Security Audit @@ -240,6 +244,8 @@ jobs: runs-on: ubuntu-latest needs: [npm-audit, codeql-analysis, secret-scanning, security-txt-validation, license-compliance, vulnerability-disclosure-policy] if: always() + permissions: + issues: write # Required for creating security alert issues steps: - name: Check security results run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4dcf661..f93445e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,10 @@ on: push: branches: [ main ] +# Default permissions for all jobs (principle of least privilege) +permissions: + contents: read + jobs: test-haskell: name: Haskell Validator Tests diff --git a/integrations/client/javascript/src/index.js b/integrations/client/javascript/src/index.js index 348b09d..c8b51c0 100644 --- a/integrations/client/javascript/src/index.js +++ b/integrations/client/javascript/src/index.js @@ -20,6 +20,43 @@ const DEFAULT_CONFIG = { theme: 'light' }; +/** + * Sanitize a URL to prevent XSS via javascript: or data: protocols + * + * @param {string} url - The URL to sanitize + * @returns {string} Sanitized URL or empty string if invalid + */ +function sanitizeUrl(url) { + if (typeof url !== 'string') { + return ''; + } + + const trimmedUrl = url.trim().toLowerCase(); + + // Block dangerous protocols + if (trimmedUrl.startsWith('javascript:') || + trimmedUrl.startsWith('data:') || + trimmedUrl.startsWith('vbscript:')) { + return ''; + } + + // Only allow http, https, and relative URLs + if (trimmedUrl.startsWith('http://') || + trimmedUrl.startsWith('https://') || + trimmedUrl.startsWith('/') || + trimmedUrl.startsWith('./') || + trimmedUrl.startsWith('../')) { + return url; + } + + // For other cases, prepend https:// if it looks like a domain + if (trimmedUrl.match(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z]{2,})+/)) { + return 'https://' + url; + } + + return ''; +} + /** * Extract license metadata from the current page * @@ -147,7 +184,8 @@ export function createLicenseBadge(options = {}) { const config = { ...DEFAULT_CONFIG, ...options }; const badge = document.createElement('a'); - badge.href = config.licenseUrl; + // Sanitize URL to prevent XSS via javascript: protocol + badge.href = sanitizeUrl(config.licenseUrl); badge.target = '_blank'; badge.rel = 'license noopener noreferrer'; badge.className = 'palimpsest-badge'; @@ -182,14 +220,33 @@ export function createLicenseBadge(options = {}) { ? `Palimpsest Licentie v${config.version}` : `Palimpsest License v${config.version}`; - badge.innerHTML = ` - - - - - - ${text} - `; + // Create SVG element safely + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '16'); + svg.setAttribute('height', '16'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.style.marginRight = '6px'; + + const paths = [ + 'M12 2L2 7L12 12L22 7L12 2Z', + 'M2 17L12 22L22 17', + 'M2 12L12 17L22 12' + ]; + + paths.forEach(d => { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '2'); + svg.appendChild(path); + }); + + badge.appendChild(svg); + + // Create text node safely (prevents XSS) + const textNode = document.createTextNode(text); + badge.appendChild(textNode); badge.addEventListener('mouseenter', () => { badge.style.borderColor = config.theme === 'dark' ? '#58a6ff' : '#0366d6'; @@ -240,29 +297,79 @@ export function createLicenseNotice(options = {}) { `; const text = config.language === 'nl' ? { - main: `Dit werk is beschermd onder de Palimpsest Licentie v${config.version}.`, + mainPrefix: 'Dit werk is beschermd onder de ', + mainLicense: `Palimpsest Licentie v${config.version}`, + mainSuffix: '.', requirement: 'Afgeleiden moeten de emotionele en culturele integriteit van het origineel behouden.', link: 'Lees de volledige licentie' } : { - main: `This work is protected under the Palimpsest License v${config.version}.`, + mainPrefix: 'This work is protected under the ', + mainLicense: `Palimpsest License v${config.version}`, + mainSuffix: '.', requirement: "Derivatives must preserve the original's emotional and cultural integrity.", link: 'Read the full license' }; - notice.innerHTML = ` -
- - - - - -
-

${text.main}

-

${text.requirement}

- ${text.link} → -
-
- `; + // Build DOM safely to prevent XSS + const container = document.createElement('div'); + container.style.cssText = 'display: flex; align-items: center;'; + + // Create SVG safely + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '24'); + svg.setAttribute('height', '24'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.style.cssText = 'margin-right: 12px; flex-shrink: 0;'; + + const pathData = [ + 'M12 2L2 7L12 12L22 7L12 2Z', + 'M2 17L12 22L22 17', + 'M2 12L12 17L22 12' + ]; + + pathData.forEach(d => { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('stroke', styles.linkColor); + path.setAttribute('stroke-width', '2'); + svg.appendChild(path); + }); + + container.appendChild(svg); + + // Create content div + const contentDiv = document.createElement('div'); + + // Main paragraph with safe text content + const mainP = document.createElement('p'); + mainP.style.cssText = 'margin: 0 0 8px 0;'; + mainP.appendChild(document.createTextNode(text.mainPrefix)); + const strong = document.createElement('strong'); + strong.textContent = text.mainLicense; + mainP.appendChild(strong); + mainP.appendChild(document.createTextNode(text.mainSuffix)); + + // Requirement paragraph + const reqP = document.createElement('p'); + reqP.style.cssText = 'margin: 0 0 8px 0; font-size: 13px; opacity: 0.8;'; + reqP.textContent = text.requirement; + + // Link with URL validation + const link = document.createElement('a'); + // Validate URL to prevent javascript: protocol injection + const sanitizedUrl = sanitizeUrl(config.licenseUrl); + link.href = sanitizedUrl; + link.style.cssText = `color: ${styles.linkColor}; text-decoration: none; font-weight: 500;`; + link.target = '_blank'; + link.rel = 'license noopener'; + link.textContent = text.link + ' →'; + + contentDiv.appendChild(mainP); + contentDiv.appendChild(reqP); + contentDiv.appendChild(link); + container.appendChild(contentDiv); + notice.appendChild(container); return notice; } diff --git a/integrations/server/python/examples/basic_flask.py b/integrations/server/python/examples/basic_flask.py index 3a930cd..fa7f1e3 100644 --- a/integrations/server/python/examples/basic_flask.py +++ b/integrations/server/python/examples/basic_flask.py @@ -167,8 +167,13 @@ def api_stories(): if __name__ == "__main__": + import os + print("Starting Flask application with Palimpsest License integration") print("Palimpsest License middleware active") print("Visit http://localhost:5000") print("License info available at http://localhost:5000/license.json") - app.run(debug=True, port=5000) + + # Use environment variable for debug mode (default to False for security) + debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true" + app.run(debug=debug_mode, port=5000)