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('")).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 = '