diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..274472510 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: "/" + groups: + github-actions: + patterns: + - "*" + schedule: + interval: weekly + cooldown: + default-days: 7 + + - package-ecosystem: npm + directory: "/" + groups: + npm: + patterns: + - "*" + schedule: + interval: weekly + cooldown: + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 2 + default-days: 7 + + - package-ecosystem: bundler + directory: "/action_text-trix" + groups: + bundler: + patterns: + - "*" + schedule: + interval: weekly + cooldown: + semver-major-days: 7 + semver-minor-days: 3 + semver-patch-days: 2 + default-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e5e165be..c1a96a8c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ on: types: [opened, synchronize] branches: [ '*' ] +permissions: {} + env: SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} @@ -19,12 +21,35 @@ env: SAUCE_TUNNEL_IDENTIFIER: trix-${{ github.run_id }} jobs: + lint-actions: + name: GitHub Actions audit + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run actionlint + uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11 + + - name: Run zizmor + uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + with: + advanced-security: false + build: name: Browser tests runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: 18 cache: "yarn" @@ -32,7 +57,7 @@ jobs: run: yarn install --frozen-lockfile - name: Start Sauce Connect if: ${{ env.SAUCE_ACCESS_KEY != '' }} - uses: saucelabs/sauce-connect-action@v3 + uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3 with: username: ${{ env.SAUCE_USERNAME }} accessKey: ${{ env.SAUCE_ACCESS_KEY }} @@ -50,13 +75,17 @@ jobs: rails-tests: name: Downstream Rails integration tests runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: 18 cache: "yarn" - - uses: ruby/setup-ruby-pkgs@v1 + - uses: ruby/setup-ruby-pkgs@2233d39c1315c667a2970436418b520a6300124e # v1.33.5 with: ruby-version: "3.4" apt-get: libvips-tools @@ -81,6 +110,8 @@ jobs: action_text-trix: name: Action Text tests runs-on: ubuntu-latest + permissions: + contents: read strategy: fail-fast: false matrix: @@ -122,12 +153,14 @@ jobs: rails_branch: main experimental: true steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: 18 cache: "yarn" - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 env: RAILS_BRANCH: ${{ matrix.rails_branch }} with: diff --git a/action_text-trix/app/assets/javascripts/trix.js b/action_text-trix/app/assets/javascripts/trix.js index 0c262a851..df6e1d783 100644 --- a/action_text-trix/app/assets/javascripts/trix.js +++ b/action_text-trix/app/assets/javascripts/trix.js @@ -1,5 +1,5 @@ /* -Trix 2.1.17 +Trix 2.1.18 Copyright © 2026 37signals, LLC */ (function (global, factory) { @@ -9,7 +9,7 @@ Copyright © 2026 37signals, LLC })(this, (function () { 'use strict'; var name = "trix"; - var version = "2.1.17"; + var version = "2.1.18"; var description = "A rich text editor for everyday writing"; var main = "dist/trix.umd.min.js"; var module = "dist/trix.esm.min.js"; @@ -6862,7 +6862,13 @@ $\ class StringPiece extends Piece { static fromJSON(pieceJSON) { - return new this(pieceJSON.string, pieceJSON.attributes); + const attributes = { + ...pieceJSON.attributes + }; + if (attributes.href && !purify.isValidAttribute("a", "href", attributes.href)) { + delete attributes.href; + } + return new this(pieceJSON.string, attributes); } constructor(string) { super(...arguments); diff --git a/action_text-trix/lib/action_text/trix/version.rb b/action_text-trix/lib/action_text/trix/version.rb index 624df4df6..2def761e7 100644 --- a/action_text-trix/lib/action_text/trix/version.rb +++ b/action_text-trix/lib/action_text/trix/version.rb @@ -1,3 +1,3 @@ module Trix - VERSION = "2.1.17" + VERSION = "2.1.18" end diff --git a/bin/ci b/bin/ci index 0fa3b7db7..f8d3f22d2 100755 --- a/bin/ci +++ b/bin/ci @@ -13,4 +13,12 @@ if [ -n "$CI" ]; then echo "GITHUB_BASE_REF: $GITHUB_BASE_REF" fi +# Lint GitHub Actions workflows +if command -v actionlint &> /dev/null; then + actionlint +fi +if command -v zizmor &> /dev/null; then + zizmor . +fi + yarn test diff --git a/bin/setup b/bin/setup index 643e0aee2..1d2bc5cbc 100755 --- a/bin/setup +++ b/bin/setup @@ -31,6 +31,15 @@ abort() { return 2 } +echo "--- Installing GitHub Actions linting tools" +{ + for tool in actionlint shellcheck zizmor; do + if ! which "$tool" > /dev/null; then + brew_install_missing "$tool" || abort "Can't find or install $tool. Install it manually." + fi + done +} >&3 2>&1 + echo "--- Installing Ruby gems" { if which rbenv > /dev/null; then diff --git a/package.json b/package.json index f80bd31c7..dc64a3a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trix", - "version": "2.1.17", + "version": "2.1.18", "description": "A rich text editor for everyday writing", "main": "dist/trix.umd.min.js", "module": "dist/trix.esm.min.js", diff --git a/src/test/unit.js b/src/test/unit.js index c580e8953..43520dae1 100644 --- a/src/test/unit.js +++ b/src/test/unit.js @@ -3,6 +3,7 @@ import "test/unit/bidi_test" import "test/unit/block_test" import "test/unit/composition_test" import "test/unit/document_test" +import "test/unit/document_json_deserialization_test" import "test/unit/document_view_test" import "test/unit/helpers/custom_elements_test" import "test/unit/html_parser_test" diff --git a/src/test/unit/document_json_deserialization_test.js b/src/test/unit/document_json_deserialization_test.js new file mode 100644 index 000000000..c61d47715 --- /dev/null +++ b/src/test/unit/document_json_deserialization_test.js @@ -0,0 +1,55 @@ +import { assert, test, testGroup } from "test/test_helper" + +import { deserializeFromContentType } from "trix/core/serialization" +import DocumentView from "trix/views/document_view" + +const deserializeAndRender = function(json) { + const document = deserializeFromContentType(json, "application/json") + return DocumentView.render(document) +} + +const buildPayloadWithHref = function(href) { + return JSON.stringify([ { + text: [ { type: "string", string: "Click me", attributes: { href } } ], + attributes: [], + htmlAttributes: {}, + } ]) +} + +testGroup("JSON deserialization sanitization", () => { + test("strips javascript: href", () => { + const element = deserializeAndRender(buildPayloadWithHref("javascript:alert(1)")) + const links = element.querySelectorAll("a[href]") + const dangerousLinks = Array.from(links).filter((link) => /javascript:/i.test(link.getAttribute("href"))) + + assert.equal(dangerousLinks.length, 0, "javascript: href should be stripped") + assert.ok(element.textContent.includes("Click me"), "link text should be preserved") + }) + + test("strips javascript: href with mixed case", () => { + const element = deserializeAndRender(buildPayloadWithHref("JavaScript:alert(1)")) + const links = element.querySelectorAll("a[href]") + const dangerousLinks = Array.from(links).filter((link) => /javascript:/i.test(link.getAttribute("href"))) + + assert.equal(dangerousLinks.length, 0, "mixed-case javascript: href should be stripped") + assert.ok(element.textContent.includes("Click me"), "link text should be preserved") + }) + + test("strips javascript: href with leading whitespace", () => { + const element = deserializeAndRender(buildPayloadWithHref(" javascript:alert(1)")) + const links = element.querySelectorAll("a[href]") + const dangerousLinks = Array.from(links).filter((link) => /javascript:/i.test(link.getAttribute("href"))) + + assert.equal(dangerousLinks.length, 0, "whitespace-padded javascript: href should be stripped") + assert.ok(element.textContent.includes("Click me"), "link text should be preserved") + }) + + test("preserves safe https: href", () => { + const element = deserializeAndRender(buildPayloadWithHref("https://example.com")) + const links = element.querySelectorAll("a[href]") + const safeLinks = Array.from(links).filter((link) => link.getAttribute("href") === "https://example.com") + + assert.equal(safeLinks.length, 1, "https: href should be preserved") + assert.ok(element.textContent.includes("Click me"), "link text should be preserved") + }) +}) diff --git a/src/trix/models/string_piece.js b/src/trix/models/string_piece.js index 1fb7e7754..a3791236f 100644 --- a/src/trix/models/string_piece.js +++ b/src/trix/models/string_piece.js @@ -1,10 +1,16 @@ +import DOMPurify from "dompurify" + import Piece from "trix/models/piece" import { normalizeNewlines } from "trix/core/helpers" export default class StringPiece extends Piece { static fromJSON(pieceJSON) { - return new this(pieceJSON.string, pieceJSON.attributes) + const attributes = { ...pieceJSON.attributes } + if (attributes.href && !DOMPurify.isValidAttribute("a", "href", attributes.href)) { + delete attributes.href + } + return new this(pieceJSON.string, attributes) } constructor(string) {