diff --git a/changelog.d/lucee-7-0-5-41.changed.md b/changelog.d/lucee-7-0-5-41.changed.md new file mode 100644 index 0000000000..0ccf3580df --- /dev/null +++ b/changelog.d/lucee-7-0-5-41.changed.md @@ -0,0 +1,2 @@ +- Updated Lucee to 7.0.5.41. New apps from `wheels new`, the starter app and the framework's own `lucee.json` now pin Lucee 7.0.5.41 (previously 7.0.0.395), and the Lucee 7 test image and CI lanes run 7.0.5.41 (previously 7.0.1.100). Lucee 7.0.5.41's Express bundle ships Tomcat 11.0.22. Existing apps keep the version in their own `lucee.json`; change `lucee.version` to `7.0.5.41` to update. +- On Lucee 7.0.5 and later, HTML that view helpers encode (`encode=true` or `encode="attributes"`) uses fewer character references. Lucee's ESAPI extension 3.x encodes only the characters that matter in each context, so `/`, `:`, `(`, `)`, `[`, `]` and spaces stay literal and a double quote becomes `"` rather than `"`; earlier Lucee releases encode them as `/`, `:` and so on. `<`, `&` and quotes are still encoded wherever they were, and browsers render both forms the same. Its canonicalization also keeps a backslash that precedes a quote (`\"`) instead of dropping it, so such a value is rendered as written. Tests that compare helper output against hard-coded encoded strings may need updating. diff --git a/cli/lucli/templates/app/lucee.json b/cli/lucli/templates/app/lucee.json index 95ae49956c..8300b4f2e7 100644 --- a/cli/lucli/templates/app/lucee.json +++ b/cli/lucli/templates/app/lucee.json @@ -1,7 +1,7 @@ { "name": "{{appName}}", "lucee": { - "version": "7.0.0.395" + "version": "7.0.5.41" }, "port": {{port}}, "shutdownPort": {{shutdownPort}}, diff --git a/compose.yml b/compose.yml index 7f9bc9b8c3..046b0c053d 100644 --- a/compose.yml +++ b/compose.yml @@ -115,7 +115,7 @@ services: build: context: ./ dockerfile: ./tools/docker/lucee7/Dockerfile - image: wheels-test-lucee7:v1.0.0 + image: wheels-test-lucee7:v1.0.1 environment: # The constructor test-context gate honours WHEELS_ENV; # the core suite runs in development (config/environment.cfm). Default to diff --git a/examples/starter-app/lucee.json b/examples/starter-app/lucee.json index ddc85ad926..fc37809907 100644 --- a/examples/starter-app/lucee.json +++ b/examples/starter-app/lucee.json @@ -1,7 +1,7 @@ { "name": "wheels-starter-app", "lucee": { - "version": "7.0.0.395" + "version": "7.0.5.41" }, "port": 8080, "shutdownPort": 8081, diff --git a/lucee.json b/lucee.json index a26c8f2498..732754e2cb 100644 --- a/lucee.json +++ b/lucee.json @@ -1,7 +1,7 @@ { "name": "wheels", "lucee": { - "version": "7.0.0.395" + "version": "7.0.5.41" }, "port": 8080, "shutdownPort": 8081, diff --git a/tools/ci/iis/install.ps1 b/tools/ci/iis/install.ps1 index 8799add99a..e3bc86f566 100644 --- a/tools/ci/iis/install.ps1 +++ b/tools/ci/iis/install.ps1 @@ -28,8 +28,8 @@ if ($p.ExitCode -notin 0, 3010) { throw "URL Rewrite install failed: $($p.ExitCo # 3. Lucee Express (Tomcat). The AJP connector on 8009 is enabled in its server.xml. $luceeZip = Join-Path $work 'lucee-express.zip' -Get-Pinned 'https://cdn.lucee.org/lucee-express-7.0.1.100.zip' ` - 'e2f995d8993747dc782a1c50899aeca40db3cf8993eaf025f8adc61c7dce87e9' $luceeZip +Get-Pinned 'https://cdn.lucee.org/lucee-express-7.0.5.41.zip' ` + '30d79340bbefeb36e162530ad1cd6c9d88ff6d29e7c517ebc60fbab06a90bf44' $luceeZip Expand-Archive -Path $luceeZip -DestinationPath 'C:\lucee' -Force $serverXml = 'C:\lucee\conf\server.xml' $xml = Get-Content $serverXml -Raw diff --git a/tools/ci/lucee.ci.json b/tools/ci/lucee.ci.json index 256d942c1e..8d291c0b74 100644 --- a/tools/ci/lucee.ci.json +++ b/tools/ci/lucee.ci.json @@ -1,7 +1,7 @@ { "name": "wheels-ci", "lucee": { - "version": "7.0.1.100" + "version": "7.0.5.41" }, "port": 30007, "shutdownPort": 30008, diff --git a/tools/docker/github/README.md b/tools/docker/github/README.md index e5a047efd0..a808dfec9b 100644 --- a/tools/docker/github/README.md +++ b/tools/docker/github/README.md @@ -75,7 +75,7 @@ strategy: |--------|----------------|------|--------------| | Lucee 5 | `wheels-lucee5-1` | 60005 | `wheels-test-lucee5:v1.0.2` | | Lucee 6 | `wheels-lucee6-1` | 60006 | `wheels-test-lucee6:v1.0.2` | -| Lucee 7 | `wheels-lucee7-1` | 60007 | `wheels-test-lucee7:v1.0.0` | +| Lucee 7 | `wheels-lucee7-1` | 60007 | `wheels-test-lucee7:v1.0.1` | | Adobe 2018 | `wheels-adobe2018-1` | 62018 | `wheels-test-adobe2018:v1.0.2` | | Adobe 2021 | `wheels-adobe2021-1` | 62021 | `wheels-test-adobe2021:v1.0.2` | | Adobe 2023 | `wheels-adobe2023-1` | 62023 | `wheels-test-adobe2023:v1.0.1` | diff --git a/tools/docker/lucee7/Dockerfile b/tools/docker/lucee7/Dockerfile index a2d9f08627..6c0fbb48b4 100644 --- a/tools/docker/lucee7/Dockerfile +++ b/tools/docker/lucee7/Dockerfile @@ -31,7 +31,7 @@ ENV ENV_MODE "remote" ENV BOX_SERVER_CFCONFIGFILE "/wheels-test-suite/CFConfig.json" ENV BOX_SERVER_PROFILE "none" ENV BOX_INSTALL TRUE -ENV BOX_SERVER_APP_CFENGINE "lucee@7.0.1+100" +ENV BOX_SERVER_APP_CFENGINE "lucee@7.0.5+41" # Set working directory WORKDIR ${APP_DIR} diff --git a/tools/docker/lucee7/server.json b/tools/docker/lucee7/server.json index 53701a106d..04b8af2137 100644 --- a/tools/docker/lucee7/server.json +++ b/tools/docker/lucee7/server.json @@ -14,7 +14,7 @@ } }, "app":{ - "cfengine":"lucee@7.0.1+100", + "cfengine":"lucee@7.0.5+41", "libDirs":"/opt/wheels-lib" }, "cfconfigFile":"CFConfig.json" diff --git a/vendor/wheels/tests/_assets/view/HtmlEncoding.cfc b/vendor/wheels/tests/_assets/view/HtmlEncoding.cfc new file mode 100644 index 0000000000..852e5fb252 --- /dev/null +++ b/vendor/wheels/tests/_assets/view/HtmlEncoding.cfc @@ -0,0 +1,235 @@ +/** + * Encoder-agnostic helpers for view specs that check HTML encoding. + * + * Engines encode the same input differently. Lucee's ESAPI extension 2.x encodes almost every + * non-alphanumeric character (`/` becomes `/`, a space in an attribute ` `, `"` becomes + * `"`), while the 3.x extension shipped with Lucee 7.0.5 (built on the OWASP Java Encoder) + * encodes only what the context needs (`"` becomes `"`, `/` stays literal). Both are safe. + * + * `normalize()` lets a spec compare output against one literal expectation on either encoder: + * every character reference is rewritten to a single canonical form. Characters that can change + * the meaning of markup (`&`, `<`, `"`, `'`) always stay encoded, so a missing encoding of one of + * them still fails the comparison. `>` is canonicalised to the literal character, because the + * OWASP encoder leaves it literal inside attribute values, where it cannot end the tag; specs that + * need `>` encoded in text assert that separately with `isSafeText()`. + * + * The safety helpers do not use the engine's encoder at all: they parse the generated markup. + */ +component { + + /** + * Rewrites every character reference to a canonical form: `&`, `<`, `"` and + * `'` for the markup-significant characters, the literal character for everything else. + */ + public string function normalize(required string html) { + var rv = ""; + var pos = 1; + var total = Len(arguments.html); + while (pos <= total) { + var found = REFind("&(##[xX][0-9a-fA-F]+|##[0-9]+|[a-zA-Z][a-zA-Z0-9]*);", arguments.html, pos, true); + if (found.pos[1] == 0) { + rv &= Mid(arguments.html, pos, total - pos + 1); + break; + } + if (found.pos[1] > pos) { + rv &= Mid(arguments.html, pos, found.pos[1] - pos); + } + var reference = Mid(arguments.html, found.pos[1], found.len[1]); + var codePoint = $codePoint(Mid(arguments.html, found.pos[2], found.len[2])); + rv &= codePoint == 0 ? reference : $canonical(codePoint); + pos = found.pos[1] + found.len[1]; + } + return rv; + } + + /** + * Fully decodes the character references in a string (the value a browser would see). + */ + public string function decode(required string value) { + var rv = ""; + var pos = 1; + var total = Len(arguments.value); + while (pos <= total) { + var found = REFind("&(##[xX][0-9a-fA-F]+|##[0-9]+|[a-zA-Z][a-zA-Z0-9]*);", arguments.value, pos, true); + if (found.pos[1] == 0) { + rv &= Mid(arguments.value, pos, total - pos + 1); + break; + } + if (found.pos[1] > pos) { + rv &= Mid(arguments.value, pos, found.pos[1] - pos); + } + var codePoint = $codePoint(Mid(arguments.value, found.pos[2], found.len[2])); + rv &= codePoint == 0 ? Mid(arguments.value, found.pos[1], found.len[1]) : Chr(codePoint); + pos = found.pos[1] + found.len[1]; + } + return rv; + } + + /** + * Returns the double-quoted attributes of every tag in `html`, in order, as structs with the + * attribute `name` and its `raw` (still encoded) value. An attribute that broke out of its + * quotes shows up here as an attribute of its own. + */ + public array function attributes(required string html) { + var rv = []; + var pos = 1; + while (true) { + var found = REFind("\s([a-zA-Z_:][-a-zA-Z0-9_:.]*)=""([^""]*)""", arguments.html, pos, true); + if (found.pos[1] == 0) { + break; + } + ArrayAppend(rv, { + name = LCase(Mid(arguments.html, found.pos[2], found.len[2])), + raw = found.len[3] > 0 ? Mid(arguments.html, found.pos[3], found.len[3]) : "" + }); + pos = found.pos[1] + found.len[1]; + } + return rv; + } + + /** + * Every opening `tag` in `html` (e.g. each ``), in order. A literal `>` inside a + * quoted attribute value does not end the tag. + */ + public array function tags(required string html, required string tag) { + return REMatchNoCase("<#arguments.tag#\b(?:[^>""]|""[^""]*"")*>", arguments.html); + } + + /** + * Whether any tag in `html` carries an attribute called `name`. + */ + public boolean function hasAttribute(required string html, required string name) { + for (var attribute in attributes(arguments.html)) { + if (attribute.name == LCase(arguments.name)) { + return true; + } + } + return false; + } + + /** + * The raw (still encoded) value of the first attribute called `name`, or "" when there is none. + */ + public string function attributeValue(required string html, required string name) { + for (var attribute in attributes(arguments.html)) { + if (attribute.name == LCase(arguments.name)) { + return attribute.raw; + } + } + return ""; + } + + /** + * The raw text between the first opening `tag` and the next `<`, or "" when the tag is absent. + * Encoded text never contains a literal `<`, so this is the whole encoded text node. A literal + * `>` inside a quoted attribute value does not end the tag. + */ + public string function innerText(required string html, required string tag) { + var found = REFindNoCase("<#arguments.tag#\b(?:[^>""]|""[^""]*"")*>([^<]*)", arguments.html, 1, true); + if (found.pos[1] == 0 || found.len[2] == 0) { + return ""; + } + return Mid(arguments.html, found.pos[2], found.len[2]); + } + + /** + * Whether a raw attribute value is safe inside double quotes: no literal `"`, `'` or `<`. + * A literal `>` is allowed, as it cannot end a quoted attribute value. + */ + public boolean function isSafeAttributeValue(required string raw) { + return !Find("""", arguments.raw) && !Find("'", arguments.raw) && !Find("<", arguments.raw); + } + + /** + * Whether raw text content carries no literal `<`, `>`, `"` or `'`. + */ + public boolean function isSafeText(required string raw) { + return !Find("<", arguments.raw) && !Find(">", arguments.raw) && !Find("""", arguments.raw) && !Find("'", arguments.raw); + } + + /** + * Checks that `input`, rendered into the `name` attribute of `html`, stayed one attribute value: + * no attribute the input tried to inject (`onclick=` in the input) appears on any tag, the value + * carries no literal `"`, `'` or `<`, and it decodes back to `input`. Returns "" when all hold, + * otherwise a description of the first problem. + */ + public string function attributeProblem(required string html, required string name, required string input) { + for (var injected in REMatch("[a-zA-Z][-a-zA-Z0-9]*=", arguments.input)) { + var injectedName = Left(injected, Len(injected) - 1); + if (hasAttribute(arguments.html, injectedName)) { + return "the input added a [#injectedName#] attribute: #arguments.html#"; + } + } + if (!hasAttribute(arguments.html, arguments.name)) { + return "no [#arguments.name#] attribute in: #arguments.html#"; + } + var raw = attributeValue(arguments.html, arguments.name); + if (!isSafeAttributeValue(raw)) { + return "[#arguments.name#] value has a literal quote or <: #raw#"; + } + if (Compare(decode(raw), arguments.input) != 0) { + return "[#arguments.name#] value decodes to [#decode(raw)#], not the input [#arguments.input#]"; + } + return ""; + } + + /** + * Checks that the raw text node `raw` encodes `input` safely: no literal `<`, `>`, `"` or `'`, + * and it decodes back to `input`. Returns "" when both hold, otherwise a description. + */ + public string function textProblem(required string raw, required string input) { + if (!isSafeText(arguments.raw)) { + return "text has a literal <, >, quote or apostrophe: #arguments.raw#"; + } + if (Compare(decode(arguments.raw), arguments.input) != 0) { + return "text decodes to [#decode(arguments.raw)#], not the input [#arguments.input#]"; + } + return ""; + } + + /** + * The code point of a character reference body (`#x2f`, `#47`, `quot`), or 0 when the named + * reference is not one this helper knows. + */ + public numeric function $codePoint(required string body) { + if (Left(arguments.body, 2) == "##x" || Left(arguments.body, 2) == "##X") { + return InputBaseN(Mid(arguments.body, 3, Len(arguments.body) - 2), 16); + } + if (Left(arguments.body, 1) == "##") { + return Val(Mid(arguments.body, 2, Len(arguments.body) - 1)); + } + switch (LCase(arguments.body)) { + case "quot": + return 34; + case "apos": + return 39; + case "amp": + return 38; + case "lt": + return 60; + case "gt": + return 62; + case "nbsp": + return 160; + } + return 0; + } + + /** + * The canonical form of one decoded character. + */ + public string function $canonical(required numeric codePoint) { + switch (arguments.codePoint) { + case 38: + return "&"; + case 60: + return "<"; + case 34: + return """; + case 39: + return "&##39;"; + } + return Chr(arguments.codePoint); + } + +} diff --git a/vendor/wheels/tests/specs/view/assetsSpec.cfc b/vendor/wheels/tests/specs/view/assetsSpec.cfc index 46435cf3a4..8e5f24e14e 100644 --- a/vendor/wheels/tests/specs/view/assetsSpec.cfc +++ b/vendor/wheels/tests/specs/view/assetsSpec.cfc @@ -15,6 +15,8 @@ component extends="wheels.WheelsTest" { function run() { + enc = new wheels.tests._assets.view.HtmlEncoding() + g = application.wo describe("Tests that assetDomain", () => { @@ -352,7 +354,7 @@ component extends="wheels.WheelsTest" { result = _controller.styleSheetLinkTag(argumentcollection = args) expected = '#Chr(10)##Chr(10)##Chr(10)##Chr(10)#' - expect(result).toBe(expected) + expect(enc.normalize(result)).toBe(enc.normalize(expected)) }) it("no automatic extension when cfm", () => { @@ -360,7 +362,7 @@ component extends="wheels.WheelsTest" { result = _controller.styleSheetLinkTag(argumentcollection = args) expected = '#Chr(10)##Chr(10)##Chr(10)#' - expect(result).toBe(expected) + expect(enc.normalize(result)).toBe(enc.normalize(expected)) }) it("supports external links", () => { @@ -368,7 +370,7 @@ component extends="wheels.WheelsTest" { result = _controller.styleSheetLinkTag(argumentcollection = args) expected = '#Chr(10)##Chr(10)##Chr(10)#' - expect(result).toBe(expected) + expect(enc.normalize(result)).toBe(enc.normalize(expected)) }) it("allows specification of delimeter", () => { @@ -377,7 +379,7 @@ component extends="wheels.WheelsTest" { result = _controller.styleSheetLinkTag(argumentcollection = args) expected = '#Chr(10)##Chr(10)##Chr(10)#' - expect(result).toBe(expected) + expect(enc.normalize(result)).toBe(enc.normalize(expected)) }) it("type media arguments", () => { @@ -387,7 +389,7 @@ component extends="wheels.WheelsTest" { result = _controller.styleSheetLinkTag(argumentcollection = args) expected = '#Chr(10)#' - expect(result).toBe(expected) + expect(enc.normalize(result)).toBe(enc.normalize(expected)) }) it("no encoding", () => { diff --git a/vendor/wheels/tests/specs/view/autoLinkSpec.cfc b/vendor/wheels/tests/specs/view/autoLinkSpec.cfc index e47c048f84..0f8f4f3d0a 100644 --- a/vendor/wheels/tests/specs/view/autoLinkSpec.cfc +++ b/vendor/wheels/tests/specs/view/autoLinkSpec.cfc @@ -2,6 +2,8 @@ component extends="wheels.WheelsTest" { function run() { + enc = new wheels.tests._assets.view.HtmlEncoding() + g = application.wo describe("Testing with plain text (no HTML)", () => { @@ -23,8 +25,8 @@ component extends="wheels.WheelsTest" { args.text = "Visit Wheels at http://wheels.dev" args.encode = true result = _controller.autoLink(argumentCollection=args) - expect(result).toInclude('Visit Wheels at http&##x3a;&##x2f;&##x2f;wheels.dev') - expect(result).toInclude('&##x2f;') + expect(enc.normalize(result)).toInclude(enc.normalize('Visit Wheels at http&##x3a;&##x2f;&##x2f;wheels.dev')) + expect(enc.attributeProblem(result, "href", "http://wheels.dev")).toBe("") expect(result).notToInclude('http://wheels.dev') + expect(enc.normalize(result)).toInclude(enc.normalize('Visit Wheels at http://wheels.dev')) expect(result).notToInclude('<') }) @@ -50,8 +52,8 @@ component extends="wheels.WheelsTest" { args.text = "Contact us at info@wheels.dev" args.encode = true result = _controller.autoLink(argumentCollection=args) - expect(result).toInclude('Contact us at info&##x40;wheels.dev') - expect(result).toInclude('&##x3a;') + expect(enc.normalize(result)).toInclude(enc.normalize('Contact us at info&##x40;wheels.dev')) + expect(enc.attributeProblem(result, "href", "mailto:info@wheels.dev")).toBe("") expect(result).notToInclude('info@wheels.dev') + expect(enc.normalize(result)).toInclude(enc.normalize('Contact us at info@wheels.dev')) }) }) } diff --git a/vendor/wheels/tests/specs/view/checkboxSpec.cfc b/vendor/wheels/tests/specs/view/checkboxSpec.cfc index a793d95992..589586a76a 100644 --- a/vendor/wheels/tests/specs/view/checkboxSpec.cfc +++ b/vendor/wheels/tests/specs/view/checkboxSpec.cfc @@ -2,6 +2,8 @@ component extends="wheels.WheelsTest" { function run() { + enc = new wheels.tests._assets.view.HtmlEncoding() + g = application.wo describe("Tests that checkBox with encoding options", () => { @@ -20,7 +22,8 @@ component extends="wheels.WheelsTest" { actual = _controller.checkBox(argumentcollection = args) expected = '' - expect(actual).toBe(expected) + expect(enc.normalize(actual)).toBe(enc.normalize(expected)) + expect(enc.textProblem(enc.innerText(actual, "label"), args.label)).toBe("") }) it("does not encode HTML in label when encode=false", () => { @@ -43,7 +46,8 @@ component extends="wheels.WheelsTest" { actual = _controller.checkBox(argumentcollection = args) expected = '' - expect(actual).toBe(expected) + expect(enc.normalize(actual)).toBe(enc.normalize(expected)) + expect(enc.attributeProblem(actual, "class", args.class)).toBe("") }) it("handles complex HTML content with encode=true", () => { @@ -54,7 +58,8 @@ component extends="wheels.WheelsTest" { actual = _controller.checkBox(argumentcollection = args) expected = '' - expect(actual).toBe(expected) + expect(enc.normalize(actual)).toBe(enc.normalize(expected)) + expect(enc.textProblem(enc.innerText(actual, "label"), args.label)).toBe("") }) }) @@ -73,7 +78,8 @@ component extends="wheels.WheelsTest" { ) e = '' - expect(e).toBe(r) + expect(enc.normalize(r)).toBe(enc.normalize(e)) + expect(enc.textProblem(enc.innerText(r, "label"), "lastname error with bold")).toBe("") }) it("does not encode HTML in label when encode=false", () => { @@ -98,7 +104,8 @@ component extends="wheels.WheelsTest" { ) e = '' - expect(e).toBe(r) + expect(enc.normalize(r)).toBe(enc.normalize(e)) + expect(enc.attributeProblem(r, "class", 'form-check" onclick="alert("xss")')).toBe("") }) it("encodes HTML in uncheckedvalue when encode=true", () => { @@ -111,7 +118,8 @@ component extends="wheels.WheelsTest" { ) e = '' - expect(e).toBe(r) + expect(enc.normalize(r)).toBe(enc.normalize(e)) + expect(enc.attributeProblem(enc.tags(r, "input")[2], "value", "")).toBe("") }) it("handles complex HTML content with encode=true", () => { @@ -124,7 +132,8 @@ component extends="wheels.WheelsTest" { ) e = '' - expect(e).toBe(r) + expect(enc.normalize(r)).toBe(enc.normalize(e)) + expect(enc.textProblem(enc.innerText(r, "label"), htmlLabel)).toBe("") }) }) } diff --git a/vendor/wheels/tests/specs/view/dateselectSpec.cfc b/vendor/wheels/tests/specs/view/dateselectSpec.cfc index 27ece30905..d2da83730c 100644 --- a/vendor/wheels/tests/specs/view/dateselectSpec.cfc +++ b/vendor/wheels/tests/specs/view/dateselectSpec.cfc @@ -2,6 +2,8 @@ component extends="wheels.WheelsTest" { function run() { + enc = new wheels.tests._assets.view.HtmlEncoding() + g = application.wo describe("Tests that dateSelect with encoding options", () => { @@ -27,7 +29,8 @@ component extends="wheels.WheelsTest" { actual = _controller.dateSelect(argumentcollection = args) expected = '