This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 378def68d7 Add typed FormDef inputs, per-registry async timeout, and
markdown expand fields
378def68d7 is described below
commit 378def68d75c4e45b04985468f787e29e329dff7
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 20 14:44:59 2026 -0400
Add typed FormDef inputs, per-registry async timeout, and markdown expand
fields
FormDef now ships typed text/textarea fields painted via createElement
(never innerHTML)
so modal submits carry field values. AsyncJobRegistry accepts a
per-instance hard timeout
so long agent jobs can exceed the 120s default without raising it globally.
DetailField
gains Format.MARKDOWN; the runtime allowlist-copies sanitizing-markdown
HTML into expand
slots, and CommonmarkMarkdownRenderer escapes raw HTML and strips unsafe
URLs.
---
.../views/markdown/CommonmarkMarkdownRenderer.java | 55 +++++-
.../server/views/markdown/MarkdownRenderer.java | 4 +-
.../markdown/CommonmarkMarkdownRenderer_Test.java | 41 +++++
.../juneau/rest/server/views/AsyncJobRegistry.java | 37 +++-
.../juneau/rest/server/views/DetailField.java | 74 +++++++-
.../apache/juneau/rest/server/views/FormDef.java | 164 +++++++++++++++--
.../juneau/rest/server/views/RowDetailDef.java | 5 +-
.../apache/juneau/rest/server/views/ViewTable.java | 31 +++-
.../org/apache/juneau/views/juneau-views.css | 31 ++++
.../org/apache/juneau/views/juneau-views.js | 204 ++++++++++++++++++++-
.../rest/server/views/AsyncJobRegistry_Test.java | 14 ++
.../juneau/rest/server/views/DetailField_Test.java | 48 +++++
.../rest/server/views/ModalDef_FormDef_Test.java | 48 ++++-
.../rest/server/views/ModalResult_BrowserTest.java | 20 ++
.../views/RawContentSink_SecurityScan_Test.java | 16 +-
.../views/ViewTable_RowDetail_Emit_Test.java | 23 +++
.../server/views/ViewsJs_ModalResult_Test.java | 51 +++++-
.../rest/server/views/ViewsJs_RowDetail_Test.java | 14 ++
.../src/test/js/modal-result.cjs | 50 +++++
.../src/test/js/row-detail.cjs | 139 +++++++++++++-
20 files changed, 1001 insertions(+), 68 deletions(-)
diff --git
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
index 7617a4fadb..d48f31b958 100644
---
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
+++
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
@@ -22,18 +22,25 @@ import java.util.*;
import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.AttributeProvider;
import org.commonmark.renderer.html.HtmlRenderer;
/**
* The default {@link MarkdownRenderer} implementation: a thin wrapper around
* <a class="doclink"
href="https://github.com/commonmark/commonmark-java">commonmark-java</a> with
the GFM tables
- * extension enabled.
+ * extension enabled, HTML-escaped raw markup, and URL allowlisting on {@code
<a href>} / {@code <img src>}.
*
* <p>
- * The GFM tables extension is on by default because real-world documents
(runbooks, onboarding guides) routinely
- * use pipe tables, and core CommonMark renders them as paragraphs of literal
pipes — shipping core-only would
- * reproduce inside the framework exactly the discovery cost this module
exists to remove.
+ * The GFM tables extension is on by default because real-world documents
(runbooks, onboarding guides, skills)
+ * routinely use pipe tables, and core CommonMark renders them as paragraphs
of literal pipes.
+ *
+ * <p>
+ * Raw HTML in the markdown source is escaped ({@code escapeHtml(true)}), so a
{@code <script>} or
+ * {@code <img onerror>} in a {@code SKILL.md} becomes visible text, not an
element. Link and image URLs are
+ * restricted to {@code http}/{@code https}/{@code mailto}, fragments, and
scheme-less relative paths;
+ * {@code javascript:}, {@code data:}, and {@code vbscript:} are stripped.
*
* <p>
* commonmark-java's {@link Parser} and {@link HtmlRenderer} are immutable and
thread-safe once built, so a single
@@ -56,12 +63,17 @@ public class CommonmarkMarkdownRenderer implements
MarkdownRenderer {
* Constructor.
*
* <p>
- * Builds a parser/renderer pair with the GFM tables extension enabled
on both.
+ * Builds a parser/renderer pair with the GFM tables extension enabled,
raw HTML escaped, and unsafe URLs
+ * stripped.
*/
public CommonmarkMarkdownRenderer() {
List<Extension> extensions = List.of(TablesExtension.create());
parser = Parser.builder().extensions(extensions).build();
- renderer =
HtmlRenderer.builder().extensions(extensions).build();
+ renderer = HtmlRenderer.builder()
+ .extensions(extensions)
+ .escapeHtml(true)
+ .attributeProviderFactory(ctx -> new
SafeUrlAttributeProvider())
+ .build();
}
@Override /* MarkdownRenderer */
@@ -70,4 +82,35 @@ public class CommonmarkMarkdownRenderer implements
MarkdownRenderer {
throw iaex("Markdown source must not be null.");
return renderer.render(parser.parse(markdown));
}
+
+ /**
+ * Whether {@code url} is safe to emit as an {@code href} or {@code
src}: {@code http}/{@code https}/
+ * {@code mailto}, a fragment, a same-origin path, or a scheme-less
relative URL.
+ *
+ * @param url The candidate URL. May be <jk>null</jk>.
+ * @return <jk>true</jk> if the URL may be copied onto an element.
+ */
+ public static boolean isSafeUrl(String url) {
+ if (url == null || url.isBlank())
+ return false;
+ var t = url.trim();
+ var lower = t.toLowerCase(Locale.ROOT);
+ if (lower.startsWith("javascript:") ||
lower.startsWith("data:") || lower.startsWith("vbscript:"))
+ return false;
+ if (lower.startsWith("http://") || lower.startsWith("https://")
|| lower.startsWith("mailto:"))
+ return true;
+ if (t.charAt(0) == '#' || t.charAt(0) == '/')
+ return true;
+ return lower.indexOf(':') < 0;
+ }
+
+ private static final class SafeUrlAttributeProvider implements
AttributeProvider {
+ @Override
+ public void setAttributes(Node node, String tagName,
Map<String,String> attributes) {
+ if ("a".equals(tagName) &&
attributes.containsKey("href") && ! isSafeUrl(attributes.get("href")))
+ attributes.remove("href");
+ if ("img".equals(tagName) &&
attributes.containsKey("src") && ! isSafeUrl(attributes.get("src")))
+ attributes.remove("src");
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
index e62bfd4dfe..deeb361844 100644
---
a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
+++
b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
@@ -45,7 +45,9 @@ import java.util.*;
*
* <p>
* The rendered HTML is intended to be dropped inside a <c>.jc-prose</c>
container so it picks up the console's
- * prose typography.
+ * prose typography. Raw HTML in the source is escaped; {@code javascript:} /
{@code data:} URLs are stripped.
+ * The row-detail runtime still allowlist-copies the fragment (never {@code
innerHTML}) when
+ * {@code DetailField.Format.MARKDOWN} is set.
*
* <h5 class='section'>See Also:</h5>
* <ul>
diff --git
a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
index 4db081af06..f890c5ac57 100644
---
a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
+++
b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
@@ -85,4 +85,45 @@ class CommonmarkMarkdownRenderer_Test extends TestBase {
assertEquals("<p>one</p>\n", RENDERER.toHtml("one"));
assertEquals("<p>two</p>\n", RENDERER.toHtml("two"));
}
+
+
//-----------------------------------------------------------------------------------------------------------------
+ // d) XSS: raw HTML escaped, javascript:/data: URLs stripped
+
//-----------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_rawScript_isEscapedNotAnElement() {
+ var html = RENDERER.toHtml("hello <script>alert(1)</script>");
+ assertFalse(html.contains("<script"), () -> html);
+ assertTrue(html.contains("<script"), () -> html);
+ }
+
+ @Test void d02_imgOnerror_isEscaped() {
+ var html = RENDERER.toHtml("<img src=x onerror=alert(1)>");
+ assertFalse(html.contains("<img"), () -> html);
+ assertTrue(html.contains("<img"), () -> html);
+ }
+
+ @Test void d03_javascriptHref_isStripped() {
+ var html = RENDERER.toHtml("[x](javascript:alert(1))");
+ assertFalse(html.contains("javascript:"), () -> html);
+ assertTrue(html.contains("<a>") || html.contains("<a >") ||
html.contains("<a>x</a>"), () -> html);
+ }
+
+ @Test void d04_httpsHref_isKept() {
+ var html = RENDERER.toHtml("[y](https://example.com)");
+ assertTrue(html.contains("href=\"https://example.com\""), () ->
html);
+ }
+
+ @Test void d05_dataUrl_isRejected() {
+
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("data:text/html,<script>"));
+
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("javascript:alert(1)"));
+
assertFalse(CommonmarkMarkdownRenderer.isSafeUrl("vbscript:msg"));
+ assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("https://x"));
+ assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("http://x"));
+ assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("mailto:a@b"));
+ assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("#frag"));
+
assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("/rest/skills"));
+
assertTrue(CommonmarkMarkdownRenderer.isSafeUrl("relative/path"));
+ assertFalse(CommonmarkMarkdownRenderer.isSafeUrl(null));
+ assertFalse(CommonmarkMarkdownRenderer.isSafeUrl(""));
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
index f88b192ca2..c1f4bdcd4d 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/AsyncJobRegistry.java
@@ -17,6 +17,7 @@
package org.apache.juneau.rest.server.views;
import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
import java.security.*;
import java.time.*;
@@ -93,19 +94,40 @@ public final class AsyncJobRegistry implements
AutoCloseable {
private final boolean ownsScheduler;
/**
- * Creates a registry with the production defaults: a system clock, the
{@link #HARD_TIMEOUT} timeout,
+ * Creates a registry with the production defaults: a system clock, the
{@link #HARD_TIMEOUT} (120s) timeout,
* {@link #MAX_OUTPUT_BYTES} / {@link #MAX_SUBSCRIBERS_PER_JOB} caps,
and a private daemon scheduler that enforces
* each job's hard timeout.
+ *
+ * <p>
+ * The 120s default is a disclosure bound for jobs that are not
long-running agent dispatches. Callers that need
+ * a longer bound (for example a Claude-length create) use {@link
#AsyncJobRegistry(Duration)} rather than raising
+ * this global default.
+ * </p>
*/
public AsyncJobRegistry() {
- this(Clock.systemUTC(), HARD_TIMEOUT, MAX_OUTPUT_BYTES,
MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
+ this(HARD_TIMEOUT);
+ }
+
+ /**
+ * Creates a registry with a caller-chosen hard per-job timeout and the
production caps / scheduler.
+ *
+ * <p>
+ * Use this when a job may run longer than {@link #HARD_TIMEOUT}
(120s). The no-arg constructor keeps that
+ * default; this overload does not change it.
+ * </p>
+ *
+ * @param timeout The hard per-job timeout. Must be a positive
duration.
+ * @throws IllegalArgumentException If {@code timeout} is
<jk>null</jk>, zero, or negative.
+ */
+ public AsyncJobRegistry(Duration timeout) {
+ this(Clock.systemUTC(), requirePositiveTimeout(timeout),
MAX_OUTPUT_BYTES, MAX_SUBSCRIBERS_PER_JOB, defaultScheduler(), true);
}
/**
* Test/advanced constructor allowing an injected clock, timeout, caps
and scheduler.
*
* @param clock The clock supplying job-creation and timeout-check
instants. Must not be <jk>null</jk>.
- * @param timeout The hard per-job timeout. Must not be <jk>null</jk>.
+ * @param timeout The hard per-job timeout. Must be a positive
duration.
* @param maxOutputBytes The per-job streamed-output cap, in bytes.
* @param maxSubscribers The per-job concurrent-subscriber cap.
* @param scheduler The scheduler that enforces each job's hard
timeout, or <jk>null</jk> to rely solely on
@@ -117,13 +139,20 @@ public final class AsyncJobRegistry implements
AutoCloseable {
private AsyncJobRegistry(Clock clock, Duration timeout, long
maxOutputBytes, int maxSubscribers, ScheduledExecutorService scheduler, boolean
ownsScheduler) {
this.clock = assertArgNotNull("clock", clock);
- this.timeout = assertArgNotNull("timeout", timeout);
+ this.timeout = requirePositiveTimeout(timeout);
this.maxOutputBytes = maxOutputBytes;
this.maxSubscribers = maxSubscribers;
this.scheduler = scheduler;
this.ownsScheduler = ownsScheduler;
}
+ private static Duration requirePositiveTimeout(Duration timeout) {
+ assertArgNotNull("timeout", timeout);
+ if (timeout.isZero() || timeout.isNegative())
+ throw iaex("AsyncJobRegistry timeout must be a positive
duration, not %s.", timeout);
+ return timeout;
+ }
+
private static ScheduledExecutorService defaultScheduler() {
return Executors.newSingleThreadScheduledExecutor(r -> {
var t = new Thread(r, "juneau-async-jobs");
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
index 34b9996317..48de306e16 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
@@ -1,17 +1,15 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
+ * contributor license agreements. See the NOTICE file distributed with this
work for additional
+ * information regarding copyright ownership. The ASF licenses this file to
You under the Apache
+ * License, Version 2.0 (the "License"); you may not use this file except in
compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
+ * Unless required by applicable law or agreed to in writing, software
distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
permissions and
* limitations under the License.
*/
package org.apache.juneau.rest.server.views;
@@ -22,19 +20,63 @@ import static org.apache.juneau.commons.utils.Shorts.*;
* One field slot in a {@link DetailSection}.
*
* <p>
- * Values are filled from the expand GET JSON {@code fields} map via {@code
textContent} only. There is no
- * {@code render} field in this slice.
+ * Values are filled from the expand GET JSON {@code fields} map. The default
{@link Format#TEXT} paints with
+ * {@code textContent} only. {@link Format#MARKDOWN} stamps {@code
data-juneau-field-format="markdown"} on the
+ * slot; the runtime copies allowlisted nodes from a {@code DOMParser}
document and never assigns
+ * {@code innerHTML}. The expand JSON value for a markdown field is the HTML
produced by a sanitizing markdown
+ * renderer (see {@code juneau-rest-server-views-markdown}); it is not the raw
markdown source.
+ *
+ * <p>
+ * This does not bump {@link RowDetailDef#CONTRACT_VERSION}: the expand
envelope is unchanged, the format
+ * attribute is additive, and a TEXT-only consumer still paints unknown
attributes via {@code textContent}.
*
* @since 10.0.0
*/
public class DetailField {
+ /**
+ * How the expand-JSON scalar is painted into the slot.
+ *
+ * <p>
+ * Each constant carries the lowercase token emitted on {@code
data-juneau-field-format}. {@link #TEXT} is
+ * omitted from the template (the default).
+ */
+ public enum Format {
+
+ /** Paint with {@code textContent}. The default. */
+ TEXT("text"),
+
+ /**
+ * Treat the expand-JSON value as sanitizing-markdown HTML and
copy allowlisted nodes into the slot.
+ * Never {@code innerHTML}.
+ */
+ MARKDOWN("markdown");
+
+ private final String wire;
+
+ Format(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Returns the lowercase wire token for this format.
+ *
+ * @return The wire token (e.g. <c>"markdown"</c>).
+ */
+ public String wire() {
+ return wire;
+ }
+ }
+
/** The key into the expand JSON {@code fields} map. Unique across the
whole {@link RowDetailDef}. */
public String data;
/** The label shown above the value slot. */
public String title;
+ /** How the slot is painted. <jk>null</jk> means {@link Format#TEXT}.
*/
+ public Format format;
+
/**
* Creates a field bound to the specified expand-JSON key.
*
@@ -53,10 +95,22 @@ public class DetailField {
* Sets the label shown above the value slot.
*
* @param value The label. May be <jk>null</jk> (the {@link #data} key
is used as a fallback at emit time).
+ * An empty string suppresses the label (used for a full-width
markdown body under a section title).
* @return This object.
*/
public DetailField title(String value) {
title = value;
return this;
}
+
+ /**
+ * Sets how the expand-JSON scalar is painted into the slot.
+ *
+ * @param value The format. <jk>null</jk> means {@link Format#TEXT}.
+ * @return This object.
+ */
+ public DetailField format(Format value) {
+ format = value;
+ return this;
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
index 4bb53972bf..af8e1d664e 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/FormDef.java
@@ -18,23 +18,31 @@ package org.apache.juneau.rest.server.views;
import static org.apache.juneau.commons.utils.Shorts.*;
+import java.util.*;
+
import org.apache.juneau.commons.bean.*;
/**
- * The declarative source of a row-action modal's input form (the form half of
{@code TODO-416}; {@code TODO-399}
- * Decision 8's "form inside the dialog").
+ * The declarative source of a row-action modal's input form (the form half of
a {@code present=dialog} action).
*
- * <h5 class='section'>FreeMarker-first</h5>
+ * <h5 class='section'>Typed inputs, not markup</h5>
+ * <p>
+ * The client paints {@link #fields} as native {@code <input>} / {@code
<textarea>} elements via
+ * {@code createElement}. Labels use {@code textContent}; prefills use {@code
.value}. The runtime never assigns
+ * {@code innerHTML} from this payload. Only {@code text} and {@code
textarea} types are legal — that is the
+ * XSS bound: hostile prefill cannot become an element.
+ * </p>
* <p>
- * The form's fields are sourced <b>FreeMarker-template-first</b>, consistent
with the rest of this module's
- * server-render story: a {@link #template} names the server-side template
that produces the form's field markup,
- * rendered against the current record. The alternative bean→form
generator ({@code FormDef.of(beanType)}) is a
- * deliberately deferred follow-on ({@code TODO-399} Decision 8's
"bean→form later"), so this MVP exposes
- * only the template source; the {@code beanType} field is reserved and
omitted from the wire until then.
+ * {@link #template} may name a server-side template for authors. It is
<b>not</b> a client HTML sink; the
+ * shipped {@code juneau-views.js} ignores it.
+ * </p>
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
- * FormDef <jv>form</jv> =
FormDef.<jsm>ofTemplate</jsm>(<js>"servlet:/incidents/ack-form.ftl"</js>);
+ * FormDef <jv>form</jv> = FormDef.<jsm>create</jsm>()
+ * .field(FormDef.Input.<jsm>of</jsm>(<js>"resolution"</js>,
<js>"Resolution comment"</js>, <js>"textarea"</js>)
+ * .required()
+ * .value(<js>""</js>));
* </p>
*
* <h5 class='section'>See Also:</h5>
@@ -45,13 +53,111 @@ import org.apache.juneau.commons.bean.*;
*
* @since 10.0.0
*/
-@BeanType(properties="template")
-@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror
field names (Juneau DSL convention).
+@BeanType(properties="template,fields")
+@SuppressWarnings({
+ "java:S1845" // Fluent-builder setters intentionally mirror field names
(Juneau DSL convention).
+})
public class FormDef {
- /** The FreeMarker template reference that renders the form's field
markup against the current record. */
+ /**
+ * A single typed form input painted client-side with {@code
createElement} (never {@code innerHTML}).
+ *
+ * @since 10.0.0
+ */
+ @BeanType(properties="name,label,type,required,value")
+ @SuppressWarnings({
+ "java:S1845" // Fluent-builder setters intentionally mirror
field names (Juneau DSL convention).
+ })
+ public static class Input {
+
+ /** The submit-body key for this field (e.g. {@code
resolution}). */
+ public String name;
+
+ /** The label shown next to the control. */
+ public String label;
+
+ /** {@code text} or {@code textarea}. */
+ public String type;
+
+ /** When {@link Boolean#TRUE}, the control is required.
Omitted from the wire otherwise. */
+ public Boolean required;
+
+ /** Optional prefill, applied via {@code .value} (never {@code
innerHTML}). */
+ public String value;
+
+ /**
+ * Creates a typed form input.
+ *
+ * @param name The submit-body key. Must not be <jk>null</jk>
or blank.
+ * @param label The visible label. Must not be <jk>null</jk>
or blank.
+ * @param type {@code text} or {@code textarea}. <jk>null</jk>
or blank defaults to {@code text}.
+ * @return A new {@link Input}.
+ * @throws IllegalArgumentException If {@code name} or {@code
label} is blank, or {@code type} is not an
+ * allowed token.
+ */
+ public static Input of(String name, String label, String type) {
+ if (name == null || name.isBlank())
+ throw iaex("FormDef.Input name must not be null
or blank.");
+ if (label == null || label.isBlank())
+ throw iaex("FormDef.Input label must not be
null or blank.");
+ var t = (type == null || type.isBlank()) ? "text" :
type;
+ if (! "text".equals(t) && ! "textarea".equals(t))
+ throw iaex("FormDef.Input type must be 'text'
or 'textarea', not '%s'.", t);
+ var i = new Input();
+ i.name = name;
+ i.label = label;
+ i.type = t;
+ return i;
+ }
+
+ /**
+ * Marks this input required.
+ *
+ * @return This object.
+ */
+ public Input required() {
+ required = Boolean.TRUE;
+ return this;
+ }
+
+ /**
+ * Sets whether this input is required.
+ *
+ * @param value <jk>true</jk> to require a value;
<jk>false</jk> omits the flag from the wire.
+ * @return This object.
+ */
+ public Input required(boolean value) {
+ required = value ? Boolean.TRUE : null;
+ return this;
+ }
+
+ /**
+ * Sets the optional prefill.
+ *
+ * @param value The prefill. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public Input value(String value) {
+ this.value = value;
+ return this;
+ }
+ }
+
+ /** Optional FreeMarker template reference for server authors; ignored
by the client. */
public String template;
+ /** Typed inputs in display order; omitted from the wire when none are
declared. */
+ public List<Input> fields;
+
+ /**
+ * Starts an empty form (add {@link #field(Input) fields} and/or a
{@link #template(String) template}).
+ *
+ * @return A new {@link FormDef}.
+ */
+ public static FormDef create() {
+ return new FormDef();
+ }
+
/**
* Creates a form sourced from the specified FreeMarker template
reference.
*
@@ -60,10 +166,36 @@ public class FormDef {
* @throws IllegalArgumentException If {@code template} is
<jk>null</jk> or blank.
*/
public static FormDef ofTemplate(String template) {
- if (template == null || template.isBlank())
+ return create().template(template);
+ }
+
+ /**
+ * Sets the optional server-author template reference. The client
never treats this as HTML.
+ *
+ * @param value The template reference. Must not be <jk>null</jk> or
blank.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>
or blank.
+ */
+ public FormDef template(String value) {
+ if (value == null || value.isBlank())
throw iaex("FormDef template must not be null or
blank.");
- var f = new FormDef();
- f.template = template;
- return f;
+ template = value;
+ return this;
+ }
+
+ /**
+ * Adds one typed input field.
+ *
+ * @param value The input. Must not be <jk>null</jk>.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public FormDef field(Input value) {
+ if (value == null)
+ throw iaex("FormDef field must not be null.");
+ if (fields == null)
+ fields = l();
+ fields.add(value);
+ return this;
}
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
index 870b889fd9..d28a445eea 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
@@ -28,8 +28,9 @@ import org.apache.juneau.rest.server.widgets.*;
*
* <p>
* Structure is emitted as a {@code <template data-juneau-row-detail>} sibling
of the view table; field values
- * arrive via a same-origin GET and are painted with {@code textContent} only.
This type is Java-only — it
- * is not part of the {@code VIEW_META} JSON sidecar.
+ * arrive via a same-origin GET. {@link DetailField.Format#TEXT} (the
default) paints with {@code textContent};
+ * {@link DetailField.Format#MARKDOWN} copies allowlisted nodes from a {@code
DOMParser} document and never
+ * assigns {@code innerHTML}. This type is Java-only — it is not part
of the {@code VIEW_META} JSON sidecar.
*
* @since 10.0.0
*/
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 3a9d61ed60..815e22b342 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++
b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -146,6 +146,12 @@ public class ViewTable {
/** Attribute carrying a {@link DetailField#data} key on each empty
field slot. */
public static final String DETAIL_FIELD_ATTR = "data-juneau-field";
+ /**
+ * Attribute carrying a {@link DetailField.Format} wire token. Omitted
for {@link DetailField.Format#TEXT}
+ * (the default).
+ */
+ public static final String DETAIL_FIELD_FORMAT_ATTR =
"data-juneau-field-format";
+
/** Attribute carrying an {@link
org.apache.juneau.rest.server.widgets.ActionRef} id on a write button. */
public static final String DETAIL_ACTION_ATTR = "data-juneau-action";
@@ -482,11 +488,7 @@ public class ViewTable {
var fieldSlots = new ArrayList<>();
if (s.fields != null) {
for (var f : s.fields) {
- fieldSlots.add(div(
- div(f.title == null ||
f.title.isBlank() ? f.data : f.title)
-
.class_("juneau-view-detail-field-title"),
- div().attr(DETAIL_FIELD_ATTR,
f.data).class_("juneau-view-detail-field-value")
- ).class_("juneau-view-detail-field"));
+ fieldSlots.add(emitDetailField(f));
}
}
kids.add(div(fieldSlots.toArray())
@@ -503,6 +505,25 @@ public class ViewTable {
.children(sections.toArray());
}
+ private static Div emitDetailField(DetailField f) {
+ var markdown = f.format == DetailField.Format.MARKDOWN;
+ var valueSlot = div().attr(DETAIL_FIELD_ATTR, f.data);
+ if (markdown) {
+ valueSlot.attr(DETAIL_FIELD_FORMAT_ATTR,
DetailField.Format.MARKDOWN.wire());
+ valueSlot.class_("juneau-view-detail-field-value
juneau-view-detail-markdown jc-prose");
+ } else {
+ valueSlot.class_("juneau-view-detail-field-value");
+ }
+ var hideTitle = markdown && f.title != null &&
f.title.isEmpty();
+ if (hideTitle)
+ return div(valueSlot).class_("juneau-view-detail-field
juneau-view-detail-field-markdown");
+ var label = f.title == null || f.title.isBlank() ? f.data :
f.title;
+ return div(
+ div(label).class_("juneau-view-detail-field-title"),
+ valueSlot
+ ).class_(markdown ? "juneau-view-detail-field
juneau-view-detail-field-markdown" : "juneau-view-detail-field");
+ }
+
private static Div
emitActionBar(org.apache.juneau.rest.server.widgets.ActionBar bar,
List<RowAction> rowActions) {
var buttons = new ArrayList<>();
for (var item : bar.items) {
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index d9d62cf856..8f710514dd 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -448,6 +448,14 @@ table.dataTable > tbody > tr > td {
font-weight: 600;
}
+.juneau-view-detail-field-markdown {
+ grid-column: 1 / -1;
+}
+
+.juneau-view-detail-markdown {
+ max-width: 96ch;
+}
+
.juneau-view-detail-status {
margin: 0.5em 0 0;
}
@@ -499,6 +507,29 @@ table.dataTable > tbody > tr > td {
margin: 0 0 0.35em 42%;
}
+.juneau-view-dialog-form {
+ margin: 0 0 1em;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75em;
+}
+
+.juneau-view-dialog-form-row label {
+ display: block;
+ font-weight: 600;
+ margin-bottom: 0.25em;
+}
+
+.juneau-view-dialog-form-row input,
+.juneau-view-dialog-form-row textarea {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.juneau-view-dialog-form-row textarea {
+ min-height: 6em;
+}
+
.juneau-view-dialog-actions {
display: flex;
gap: 0.5em;
diff --git
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 7600fa4770..37a4e7d23b 100644
---
a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++
b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -966,16 +966,129 @@
}
/**
- * Fills `[data-juneau-field]` slots from an expand JSON `fields` map
via textContent only. Unknown keys are
- * dropped; missing keys and non-scalars become empty.
+ * Tag names (uppercase) the markdown slot copier will create.
Everything else is unwrapped (children kept)
+ * except the drop-with-children set in {@link #fillMarkdownSlot}.
+ */
+ const MARKDOWN_ALLOWED_TAGS = {
+ P: 1, BR: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
+ UL: 1, OL: 1, LI: 1, PRE: 1, CODE: 1, EM: 1, STRONG: 1, A: 1,
BLOCKQUOTE: 1, HR: 1,
+ TABLE: 1, THEAD: 1, TBODY: 1, TR: 1, TH: 1, TD: 1, DEL: 1, SUP:
1, SUB: 1
+ };
+
+ /** Tags whose children are discarded, not unwrapped (script body must
not become text). */
+ const MARKDOWN_DROP_TAGS = {
+ SCRIPT: 1, STYLE: 1, IFRAME: 1, OBJECT: 1, EMBED: 1, LINK: 1,
META: 1, BASE: 1, FORM: 1, INPUT: 1, SVG: 1, IMG: 1
+ };
+
+ /**
+ * Whether `href` is safe to copy onto an {@code <a>}: http(s), mailto,
same-origin path, fragment, or a
+ * scheme-less relative URL. javascript:/data:/vbscript: and other
schemes are rejected.
+ */
+ function isSafeMarkdownHref(href) {
+ if (href == null) return false;
+ const t = String(href).trim();
+ if (!t) return false;
+ const lower = t.toLowerCase();
+ if (lower.startsWith("javascript:") ||
lower.startsWith("data:") || lower.startsWith("vbscript:"))
+ return false;
+ if (lower.startsWith("http://") || lower.startsWith("https://")
|| lower.startsWith("mailto:"))
+ return true;
+ if (t.charAt(0) === "#" || t.charAt(0) === "/")
+ return true;
+ return lower.indexOf(":") < 0;
+ }
+
+ function clearElementChildren(el) {
+ if (!el) return;
+ if (typeof el.replaceChildren === "function") {
+ el.replaceChildren();
+ return;
+ }
+ while (el.firstChild)
+ el.removeChild(el.firstChild);
+ }
+
+ function copyAllowedMarkdownAttrs(from, to) {
+ if (!from || !to || typeof to.setAttribute !== "function")
return;
+ if (from.tagName === "A") {
+ const href = typeof from.getAttribute === "function" ?
from.getAttribute("href") : null;
+ if (isSafeMarkdownHref(href))
+ to.setAttribute("href", href);
+ }
+ }
+
+ function copySanitizedMarkdownChildren(from, to, doc) {
+ if (!from || !to || !from.childNodes) return;
+ const kids = from.childNodes;
+ for (let i = 0; i < kids.length; i++) {
+ const n = kids[i];
+ if (!n) continue;
+ if (n.nodeType === 3) {
+ const text = n.nodeValue == null ? "" :
String(n.nodeValue);
+ if (doc && typeof doc.createTextNode ===
"function")
+
to.appendChild(doc.createTextNode(text));
+ continue;
+ }
+ if (n.nodeType !== 1) continue;
+ const tag = n.tagName ? String(n.tagName).toUpperCase()
: "";
+ if (MARKDOWN_DROP_TAGS[tag])
+ continue;
+ if (!MARKDOWN_ALLOWED_TAGS[tag]) {
+ copySanitizedMarkdownChildren(n, to, doc);
+ continue;
+ }
+ const dest = doc.createElement(tag.toLowerCase());
+ copyAllowedMarkdownAttrs(n, dest);
+ copySanitizedMarkdownChildren(n, dest, doc);
+ to.appendChild(dest);
+ }
+ }
+
+ /**
+ * Paints a markdown field slot from sanitizing-markdown HTML. Parses
with {@code DOMParser} (does not
+ * execute script) and copies allowlisted nodes via {@code
createElement}/{@code createTextNode}. Never
+ * assigns {@code innerHTML}. Missing {@code DOMParser} fails closed
to {@code textContent}.
+ */
+ function fillMarkdownSlot(el, html) {
+ clearElementChildren(el);
+ if (html == null || html === "") return;
+ const src = String(html);
+ const doc = typeof document !== "undefined" ? document : null;
+ const Parser = typeof DOMParser !== "undefined" ? DOMParser
+ : (typeof window !== "undefined" ? window.DOMParser :
null);
+ if (!Parser || !doc || typeof doc.createElement !== "function")
{
+ el.textContent = src;
+ return;
+ }
+ let parsed;
+ try {
+ parsed = new Parser().parseFromString("<div>" + src +
"</div>", "text/html");
+ } catch (e) {
+ el.textContent = src;
+ return;
+ }
+ const wrap = parsed && parsed.body && parsed.body.firstChild;
+ if (!wrap) return;
+ copySanitizedMarkdownChildren(wrap, el, doc);
+ }
+
+ /**
+ * Fills `[data-juneau-field]` slots from an expand JSON `fields` map.
TEXT slots (the default, and any
+ * unknown format) use textContent only. {@code
data-juneau-field-format="markdown"} slots use
+ * fillMarkdownSlot. Unknown keys are dropped; missing keys and
non-scalars become empty.
*/
function fillDetailSlots(root, fields) {
if (!root || !root.querySelectorAll) return;
const map = fields && typeof fields === "object" ? fields : {};
const slots = root.querySelectorAll("[data-juneau-field]");
for (let i = 0; i < slots.length; i++) {
- const key = slots[i].getAttribute("data-juneau-field");
- slots[i].textContent = Object.hasOwn(map, key) ?
scalarFieldValue(map[key]) : "";
+ const slot = slots[i];
+ const key = slot.getAttribute("data-juneau-field");
+ const value = Object.hasOwn(map, key) ?
scalarFieldValue(map[key]) : "";
+ if (slot.getAttribute("data-juneau-field-format") ===
"markdown")
+ fillMarkdownSlot(slot, value);
+ else
+ slot.textContent = value;
}
}
@@ -1845,6 +1958,8 @@
dialog.appendChild(dl);
}
+ appendDialogForm(dialog, modal && modal.form);
+
const actions = document.createElement("div");
actions.className = "juneau-view-dialog-actions";
const cancelBtn = document.createElement("button");
@@ -1863,6 +1978,69 @@
return { backdrop: backdrop, dialog: dialog, confirmBtn:
confirmBtn, cancelBtn: cancelBtn };
}
+ /** Whether `type` is a legal FormDef input token. Anything else is
skipped (typed inputs only). */
+ function isTypedFormInputType(type) {
+ return type === "text" || type === "textarea";
+ }
+
+ /**
+ * Paints typed form fields as native label+input/textarea controls via
createElement. Labels use textContent;
+ * prefills use `.value`. Never innerHTML, never a template-markup
sink (that FormDef field is a server-author
+ * reference). Unknown types are skipped so a hostile type token
cannot become an element.
+ */
+ function appendDialogForm(dialog, form) {
+ if (!dialog || !form || !form.fields || !form.fields.length)
return;
+ const wrap = document.createElement("div");
+ wrap.className = "juneau-view-dialog-form";
+ wrap.setAttribute("data-testid", "dialog-form");
+ form.fields.forEach(function (f) {
+ if (!f || f.name == null || String(f.name) === "")
return;
+ const type = (f.type == null || f.type === "") ? "text"
: String(f.type);
+ if (!isTypedFormInputType(type)) return;
+ const row = document.createElement("div");
+ row.className = "juneau-view-dialog-form-row";
+ const id = "juneau-dialog-field-" + String(f.name);
+ const label = document.createElement("label");
+ label.setAttribute("for", id);
+ label.textContent = f.label != null ? String(f.label) :
String(f.name);
+ const input = type === "textarea" ?
document.createElement("textarea") : document.createElement("input");
+ if (type !== "textarea") input.type = "text";
+ input.id = id;
+ input.name = String(f.name);
+ input.setAttribute("data-juneau-form-field",
String(f.name));
+ if (f.required) input.required = true;
+ if (f.value != null) input.value = String(f.value);
+ row.appendChild(label);
+ row.appendChild(input);
+ wrap.appendChild(row);
+ });
+ if (wrap.childNodes.length)
+ dialog.appendChild(wrap);
+ }
+
+ /**
+ * Reads typed form controls from a dialog via `.value` (never
textContent of the control, never innerHTML)
+ * into a `{ name: value }` map for the submit body.
+ */
+ function collectDialogFormFields(dialog) {
+ const out = {};
+ if (!dialog || !dialog.querySelectorAll) return out;
+ const nodes =
dialog.querySelectorAll("[data-juneau-form-field]");
+ for (let i = 0; i < nodes.length; i++) {
+ const el = nodes[i];
+ const name = el.getAttribute ?
el.getAttribute("data-juneau-form-field") : null;
+ if (name == null || name === "") continue;
+ const tag = el.tagName ?
String(el.tagName).toLowerCase() : "";
+ if (tag !== "input" && tag !== "textarea") continue;
+ if (tag === "input") {
+ const itype = el.type ?
String(el.type).toLowerCase() : "text";
+ if (itype !== "text") continue;
+ }
+ out[name] = el.value != null ? String(el.value) : "";
+ }
+ return out;
+ }
+
/** Shows a dialog overlay for an action and wires its confirm (submit)
/ cancel (dismiss) buttons. */
function showActionDialog(modal, action, table, tr, ctx) {
const ui = buildDialogOverlay(modal, action);
@@ -1872,8 +2050,9 @@
}
ui.cancelBtn.addEventListener("click", close);
ui.confirmBtn.addEventListener("click", function () {
+ const fields = collectDialogFormFields(ui.dialog);
close();
- submitActionDialog(modal, action, table, tr, ctx);
+ submitActionDialog(modal, action, table, tr, ctx,
fields);
});
if (ctx) ctx._actionDialog = ui.backdrop;
document.body.appendChild(ui.backdrop);
@@ -1883,14 +2062,18 @@
/**
* Issues the dialog's non-safe submit, carrying the server-minted
idempotency key and the row's targetId so the
* server can check the key's `(action, targetId)` binding (HIGH-8) - a
double-click / re-submit / browser retry
- * therefore all carry the SAME key. Delegates the fail-closed CSRF
submit + in-flight marker + typed-result
- * settling to submitRowAction(...).
+ * therefore all carry the SAME key. Typed FormDef values collected
via `.value` ride in `extra.fields`.
+ * Delegates the fail-closed CSRF submit + in-flight marker +
typed-result settling to submitRowAction(...).
*/
- function submitActionDialog(modal, action, table, tr, ctx) {
+ function submitActionDialog(modal, action, table, tr, ctx, fields) {
const extra = {};
const targetId = (tr && tr.getAttribute) ?
tr.getAttribute("data-juneau-row-id") : null;
if (targetId != null) extra.targetId = targetId;
if (modal && modal.idempotencyKey != null) extra.idempotencyKey
= modal.idempotencyKey;
+ if (fields && typeof fields === "object") {
+ const keys = Object.keys(fields);
+ if (keys.length) extra.fields = fields;
+ }
submitRowAction(action, table, tr, ctx, extra);
}
@@ -2384,6 +2567,8 @@
isSafeDetailUrl: isSafeDetailUrl,
substituteDetailUrl: substituteDetailUrl,
scalarFieldValue: scalarFieldValue,
+ isSafeMarkdownHref: isSafeMarkdownHref,
+ fillMarkdownSlot: fillMarkdownSlot,
fillDetailSlots: fillDetailSlots,
findRowDetailTemplate: findRowDetailTemplate,
detailCoalesceKey: detailCoalesceKey,
@@ -2417,6 +2602,9 @@
renderActionOutcome: renderActionOutcome,
openActionDialog: openActionDialog,
buildDialogOverlay: buildDialogOverlay,
+ appendDialogForm: appendDialogForm,
+ collectDialogFormFields: collectDialogFormFields,
+ isTypedFormInputType: isTypedFormInputType,
showActionDialog: showActionDialog,
submitActionDialog: submitActionDialog,
// Async jobs + SSE streaming (TODO-425) - exposed for the
canary and manual verification. The job-running
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
index 78000040be..acc74b9b89 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/AsyncJobRegistry_Test.java
@@ -190,4 +190,18 @@ class AsyncJobRegistry_Test extends TestBase {
assertEquals(120,
AsyncJobRegistry.HARD_TIMEOUT.getSeconds());
}
}
+
+ @Test void h02_publicTimeoutConstructorHonorsLongerDuration() {
+ try (var r = new AsyncJobRegistry(Duration.ofMinutes(10))) {
+ var job = r.create();
+ assertEquals(Duration.ofMinutes(10),
Duration.between(job.createdAt(), job.deadline()));
+ assertEquals(120,
AsyncJobRegistry.HARD_TIMEOUT.getSeconds(), "global default must stay 120s");
+ }
+ }
+
+ @Test void h03_publicTimeoutConstructorRejectsNonPositive() {
+ assertThrows(IllegalArgumentException.class, () -> new
AsyncJobRegistry((Duration) null));
+ assertThrows(IllegalArgumentException.class, () -> new
AsyncJobRegistry(Duration.ZERO));
+ assertThrows(IllegalArgumentException.class, () -> new
AsyncJobRegistry(Duration.ofSeconds(-1)));
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
new file mode 100644
index 0000000000..f9dc760712
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DetailField_Test.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.views;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@link DetailField} factory, fluent setters, and {@link DetailField.Format}
wire tokens.
+ */
+class DetailField_Test extends TestBase {
+
+ @Test void a01_of_setsData() {
+ var f = DetailField.of("body");
+ assertEquals("body", f.data);
+ assertNull(f.title);
+ assertNull(f.format);
+ }
+
+ @Test void a02_of_blankRejected() {
+ assertThrows(IllegalArgumentException.class, () ->
DetailField.of(""));
+ assertThrows(IllegalArgumentException.class, () ->
DetailField.of(null));
+ }
+
+ @Test void a03_fluentFormatAndTitle() {
+ var f =
DetailField.of("body").title("SKILL.md").format(DetailField.Format.MARKDOWN);
+ assertEquals("SKILL.md", f.title);
+ assertEquals(DetailField.Format.MARKDOWN, f.format);
+ assertEquals("markdown", f.format.wire());
+ assertEquals("text", DetailField.Format.TEXT.wire());
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
index f7a54274de..a669fc8dcd 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalDef_FormDef_Test.java
@@ -29,9 +29,9 @@ import org.junit.jupiter.api.*;
* confirmation fetch returns (design doc ยง6.2; the modal/form half of {@code
TODO-416}).
*
* <p>
- * The confirmation body is typed structured fields painted client-side with
{@code textContent} (never
- * {@code innerHTML}); this test pins that field shape and the
FreeMarker-first form source, plus the omit-when-unset
- * rule for the optional form and idempotency key.
+ * The confirmation body is typed fields painted with textContent (never
innerHTML); this test pins that field
+ * shape, the typed FormDef inputs the client paints (never template markup),
plus the omit-when-unset rule for
+ * the optional form and idempotency key.
*/
class ModalDef_FormDef_Test extends TestBase {
@@ -113,4 +113,46 @@ class ModalDef_FormDef_Test extends TestBase {
assertTrue(e.getMessage().contains("blank"), e::getMessage);
assertThrows(IllegalArgumentException.class, () ->
FormDef.ofTemplate(null));
}
+
+ @Test void c04_formDef_serializesTypedInputs() {
+ var form = FormDef.create()
+ .field(FormDef.Input.of("resolution", "Resolution
comment", "textarea").required().value("done"));
+ var json = Json.of(form);
+ var expected = Json.to("""
+ {"fields":[{"name":"resolution","label":"Resolution
comment","type":"textarea","required":true,"value":"done"}]}
+ """, Map.class);
+ assertEquals(expected, Json.to(json, Map.class), json);
+ assertFalse(json.contains("\"template\""), json);
+ }
+
+ @Test void c05_formDef_textDefaultAndOptionalPrefillOmitted() {
+ var json =
Json.of(FormDef.create().field(FormDef.Input.of("note", "Note", null)));
+ assertTrue(json.contains("\"type\":\"text\""), json);
+ assertFalse(json.contains("\"required\""), json);
+ assertFalse(json.contains("\"value\""), json);
+ }
+
+ @Test void c06_formDef_inputBlankNameOrLabelThrows() {
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of(" ", "L", "text"));
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of(null, "L", "text"));
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of("n", " ", "text"));
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of("n", null, "text"));
+ }
+
+ @Test void c07_formDef_unknownTypeThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of("n", "L", "password"));
+ assertTrue(e.getMessage().contains("text"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.Input.of("n", "L", "<img>"));
+ }
+
+ @Test void c08_formDef_nullFieldThrows() {
+ assertThrows(IllegalArgumentException.class, () ->
FormDef.create().field(null));
+ }
+
+ @Test void c09_formDef_templateAndFieldsTogether() {
+ var json = Json.of(FormDef.ofTemplate("servlet:/x.ftl")
+ .field(FormDef.Input.of("resolution", "Resolution",
"textarea")));
+ assertTrue(json.contains("\"template\":\"servlet:/x.ftl\""),
json);
+ assertTrue(json.contains("\"name\":\"resolution\""), json);
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
index cc497eaf4a..56448dcf1b 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ModalResult_BrowserTest.java
@@ -228,4 +228,24 @@ class ModalResult_BrowserTest extends TestBase {
assertTrue(body.contains("ack"), () -> "action id not carried
on submit: " + report);
assertEquals(Boolean.TRUE, m.get("backdropClosedAfterConfirm"),
() -> "modal did not close on confirm: " + report);
}
+
+ @Test void e04_formDefInputsPaintTypedControlsAndSubmitFieldValues() {
+ var f = sub("form");
+ assertEquals(Boolean.TRUE, f.get("formVisible"), () -> "FormDef
inputs not visible: " + report);
+ assertEquals("<img src=x onerror=alert(1)>",
f.get("textareaPrefill"), () -> report.toString());
+ assertEquals("ok", f.get("notePrefill"), () ->
report.toString());
+ assertEquals(Boolean.TRUE, f.get("passwordSkipped"), () ->
"non-text type was painted: " + report);
+ assertEquals(0L, ((Number)
f.get("injectedImgCount")).longValue(),
+ () -> "hostile prefill became an element: " + report);
+ assertEquals(Boolean.TRUE, f.get("templateNotInFormHtml"), ()
-> "form.template was used as markup: " + report);
+ assertEquals(Boolean.TRUE, f.get("submitIssued"), () ->
"confirm did not submit: " + report);
+ var body = String.valueOf(f.get("submitBody"));
+ assertTrue(body.contains("key-close"), () -> "idempotency key
missing: " + report);
+ assertTrue(body.contains("QABCDEF"), () -> "target id missing:
" + report);
+ assertTrue(body.contains("fixed in change"), () -> "edited
textarea value missing: " + report);
+ assertTrue(body.contains("\"fields\""), () -> "fields object
missing: " + report);
+ assertTrue(body.contains("resolution"), () ->
report.toString());
+ assertFalse(body.contains("skipme"), () -> "skipped type leaked
into submit: " + report);
+ assertFalse(body.contains("secret"), () -> "skipped type value
leaked: " + report);
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
index d70df13f59..6206619236 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
@@ -334,17 +334,29 @@ class RawContentSink_SecurityScan_Test extends TestBase {
var root = requireModuleRoot();
var rel = Path.of("src", "main", "resources", "org", "apache",
"juneau", "views", "juneau-views.js");
var source = Files.readString(root.resolve(rel));
- var needle = "slots[i].textContent = Object.hasOwn(map, key) ?
scalarFieldValue(map[key]) : \"\";";
+ var needle = "slot.textContent = value;";
assertTrue(source.contains(needle), "fillDetailSlots assignment
moved - update this fixture");
var fn = extractFunction(source, "function fillDetailSlots(");
var clean =
RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), fn);
assertEquals(List.of(), clean.violations());
- var mutated = fn.replace("slots[i].textContent",
"slots[i].innerHTML");
+ var mutated = fn.replace("slot.textContent", "slot.innerHTML");
var mutatedResult =
RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), mutated);
assertTrue(mutatedResult.violations().size() >= 1,
() -> "mutating fillDetailSlots to innerHTML must be
flagged: " + mutatedResult.violations());
}
+ @Test void d04_fillMarkdownSlot_hasNoInnerHtml() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "resources", "org", "apache",
"juneau", "views", "juneau-views.js");
+ var source = Files.readString(root.resolve(rel));
+ var fn = extractFunction(source, "function fillMarkdownSlot(");
+ assertTrue(fn.contains("DOMParser"), fn);
+ assertTrue(fn.contains("createElement"), fn);
+ assertFalse(fn.contains("innerHTML"), fn);
+ var r = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(),
fn);
+ assertEquals(List.of(), r.violations(), () -> "fillMarkdownSlot
must not assign innerHTML: " + r.violations());
+ }
+
private static String extractFunction(String body, String signature) {
var start = body.indexOf(signature);
assertTrue(start >= 0, () -> "'" + signature + "' not found");
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
index 94e8c6017a..44745b7f27 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
@@ -113,4 +113,27 @@ class ViewTable_RowDetail_Emit_Test extends TestBase {
assertTrue(html.contains("data-juneau-row-detail"), html);
assertTrue(html.contains("data-juneau-detail-section=\"overview\""), html);
}
+
+ @Test void a08_markdownFormat_stampsAttribute_textDoesNot() {
+ var v = ViewDef.create("skills")
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data")
+ .columns(Column.of("id").title("Id"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("body",
"SKILL.md")
+ .columns(1)
+ .fields(
+
DetailField.of("name").title("Name"),
+
DetailField.of("body").title("").format(DetailField.Format.MARKDOWN))))
+ .build();
+ var html = Html.of(ViewTable.of(v));
+ assertTrue(html.contains("data-juneau-field=\"name\""), html);
+ assertFalse(html.contains("data-juneau-field-format=\"text\""),
html);
+
assertTrue(html.contains("data-juneau-field-format=\"markdown\""), html);
+ assertTrue(html.contains("data-juneau-field=\"body\""), html);
+ assertTrue(html.contains("juneau-view-detail-markdown"), html);
+ assertTrue(html.contains("jc-prose"), html);
+ assertFalse(html.contains(">body</div>"), "empty markdown title
must not fall back to the data key: " + html);
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
index 5cbcb2bfe6..b099acd8fb 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -94,7 +94,8 @@ class ViewsJs_ModalResult_Test extends TestBase {
// innerHTML uses live only in the trusted icon/caret render
helpers, never in the action-result path.
var body = viewsJs();
for (var sig : new String[]{"function settleActionResponse(",
"function renderActionOutcome(",
- "function showActionDialog(", "function
submitActionDialog(", "function openActionDialog("}) {
+ "function showActionDialog(", "function
submitActionDialog(", "function openActionDialog(",
+ "function appendDialogForm(", "function
collectDialogFormFields("}) {
var f = fn(body, sig);
assertFalse(f.contains(".innerHTML"), () -> sig + "
must never use innerHTML:\n" + f);
assertFalse(f.contains("insertAdjacentHTML"), () -> sig
+ " must never use insertAdjacentHTML:\n" + f);
@@ -231,4 +232,52 @@ class ViewsJs_ModalResult_Test extends TestBase {
assertTrue(s.contains("idempotencyKey"), s);
assertTrue(s.contains("targetId"), s);
}
+
+
//------------------------------------------------------------------------------------------------------------------
+ // h) FormDef inputs: createElement + textContent / .value, never
innerHTML; submit collects field values
+
//------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_appendDialogFormUsesCreateElementNeverInnerHtml() throws
Exception {
+ var body = viewsJs();
+ var append = fn(body, "function appendDialogForm(");
+ assertTrue(append.contains("createElement(\"label\")"), append);
+ assertTrue(append.contains("createElement(\"input\")"), append);
+ assertTrue(append.contains("createElement(\"textarea\")"),
append);
+ assertTrue(append.contains(".textContent"), append);
+ assertTrue(append.contains(".value"), append);
+ assertFalse(append.contains(".innerHTML"), () -> "FormDef paint
must never use innerHTML:\n" + append);
+ assertFalse(append.contains("insertAdjacentHTML"), append);
+ assertFalse(append.contains("form.template"), () -> "client
must not consume form.template as markup:\n" + append);
+ }
+
+ @Test void h02_collectDialogFormFieldsUsesValueNotInnerHtml() throws
Exception {
+ var body = viewsJs();
+ var collect = fn(body, "function collectDialogFormFields(");
+ assertTrue(collect.contains(".value"), collect);
+ assertFalse(collect.contains(".innerHTML"), () -> "collect must
never use innerHTML:\n" + collect);
+ assertTrue(collect.contains("data-juneau-form-field"), collect);
+ }
+
+ @Test void h03_submitDialogMergesCollectedFields() throws Exception {
+ var body = viewsJs();
+ var s = fn(body, "function submitActionDialog(");
+ assertTrue(s.contains("extra.fields"), s);
+ assertTrue(s.contains("idempotencyKey"), s);
+ assertTrue(s.contains("targetId"), s);
+ }
+
+ @Test void h04_typedInputsOnly_textAndTextarea() throws Exception {
+ var body = viewsJs();
+ var typed = fn(body, "function isTypedFormInputType(");
+ assertTrue(typed.contains("\"text\""), typed);
+ assertTrue(typed.contains("\"textarea\""), typed);
+ var append = fn(body, "function appendDialogForm(");
+ assertTrue(append.contains("isTypedFormInputType(type)"),
append);
+ }
+
+ @Test void h05_buildDialogOverlayPaintsForm() throws Exception {
+ var body = viewsJs();
+ var build = fn(body, "function buildDialogOverlay(");
+ assertTrue(build.contains("appendDialogForm(dialog, modal &&
modal.form)"), build);
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
index f270630f7e..36da2d7a24 100644
---
a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
+++
b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
@@ -48,6 +48,8 @@ class ViewsJs_RowDetail_Test extends TestBase {
"isSafeDetailUrl: isSafeDetailUrl",
"substituteDetailUrl: substituteDetailUrl",
"scalarFieldValue: scalarFieldValue",
+ "isSafeMarkdownHref: isSafeMarkdownHref",
+ "fillMarkdownSlot: fillMarkdownSlot",
"fillDetailSlots: fillDetailSlots",
"findRowDetailTemplate: findRowDetailTemplate",
"JUNEAU_ROW_DETAIL_CONTRACT_VERSION:
JUNEAU_ROW_DETAIL_CONTRACT_VERSION"
@@ -170,6 +172,18 @@ class ViewsJs_RowDetail_Test extends TestBase {
assertTrue(String.valueOf(r.get("fill_xss")).contains("<img"));
assertEquals("42", r.get("fill_num"));
assertEquals("", r.get("fill_missing"));
+ assertEquals(true, r.get("hasFillMarkdown"));
+ assertEquals(false, r.get("md_hasScript"));
+ assertEquals(false, r.get("md_hasImg"));
+ assertEquals(false, r.get("md_jsHref"));
+ assertEquals(true, r.get("md_httpsHref"));
+ assertEquals(true, r.get("md_textHasOk"));
+ assertEquals(true, r.get("md_textHasX"));
+ assertEquals(true, r.get("md_textHasY"));
+ assertEquals(false, r.get("md_textHasAlert"));
+ assertEquals(false, r.get("href_js"));
+ assertEquals(true, r.get("href_https"));
+ assertEquals(false, r.get("href_data"));
}
@Test void b04_404500_actionRefButtonless_collapseRemains() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
index fef9bb651f..6b3c251a4d 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/modal-result.cjs
@@ -194,6 +194,56 @@ const PROBE = async function () {
}
}
+ // ---- FormDef inputs: typed textarea/text via createElement + .value;
XSS prefill stays inert ----
+ {
+ const dom = makeRow('QABCDEF');
+ const evil = '<img src=x onerror=alert(1)>';
+ const modal = {
+ title: 'Close this incident?',
+ fields: [{ label: 'Incident', value: evil }],
+ form: {
+ template: '<img src=x onerror=alert(2)>',
+ fields: [
+ { name: 'resolution', label:
'Resolution comment', type: 'textarea', required: true, value: evil },
+ { name: 'note', label: 'Note', type:
'text', value: 'ok' },
+ { name: 'skipme', label: 'Bad', type:
'password', value: 'secret' }
+ ]
+ },
+ idempotencyKey: 'key-close'
+ };
+ const action = { id: 'close', label: 'Close', endpoint:
'/x/close', method: 'POST', present: 'dialog' };
+ const ui = init.showActionDialog(modal, action, dom.table,
dom.tr, {});
+ await tick();
+ const backdrop =
document.querySelector('.juneau-view-dialog-backdrop');
+ const formEl = backdrop ?
backdrop.querySelector('.juneau-view-dialog-form') : null;
+ const textarea = formEl ?
formEl.querySelector('textarea[data-juneau-form-field="resolution"]') : null;
+ const text = formEl ?
formEl.querySelector('input[data-juneau-form-field="note"]') : null;
+ const password = formEl ?
formEl.querySelector('[data-juneau-form-field="skipme"]') : null;
+ out.form = {
+ formVisible: rendered(formEl),
+ textareaPrefill: textarea ? textarea.value : null,
+ notePrefill: text ? text.value : null,
+ passwordSkipped: !password,
+ injectedImgCount: backdrop ?
backdrop.querySelectorAll('img').length : -1,
+ templateNotInFormHtml: formEl ?
formEl.innerHTML.indexOf('<img') < 0 : false
+ };
+ if (textarea) textarea.value = 'fixed in change';
+ const fetchCalls = [];
+ const realFetch = window.fetch;
+ window.fetch = function (url, opts) {
+ fetchCalls.push({ url: url, opts: opts });
+ return Promise.resolve(resp({ ok: true, status: 200,
+ body: JSON.stringify({ contractVersion: V,
outcome: 'success' }) }));
+ };
+ dom.table.setAttribute('data-juneau-csrf', 'tok-xyz');
+ ui.confirmBtn.click();
+ await tick(); await tick();
+ window.fetch = realFetch;
+ out.form.submitIssued = fetchCalls.length > 0;
+ if (fetchCalls.length > 0)
+ out.form.submitBody = fetchCalls[0].opts.body;
+ }
+
return out;
};
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
index 82408bbd65..28dbc27ba8 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
@@ -36,24 +36,98 @@ if (!viewsJsPath) {
process.exit(2);
}
+function el(tag) {
+ const node = {
+ nodeType: 1,
+ tagName: String(tag).toUpperCase(),
+ childNodes: [],
+ attrs: {},
+ parentNode: null,
+ get firstChild() { return this.childNodes[0] || null; },
+ getAttribute: function (k) { return Object.hasOwn(this.attrs,
k) ? this.attrs[k] : null; },
+ setAttribute: function (k, v) { this.attrs[k] = v == null ? ''
: String(v); },
+ appendChild: function (c) {
+ this.childNodes.push(c);
+ c.parentNode = this;
+ return c;
+ },
+ removeChild: function (c) {
+ const i = this.childNodes.indexOf(c);
+ if (i >= 0) this.childNodes.splice(i, 1);
+ return c;
+ },
+ replaceChildren: function () { this.childNodes.length = 0; },
+ set textContent(v) { this.childNodes.length = 0; this._text = v
== null ? '' : String(v); },
+ get textContent() {
+ if (this.childNodes.length === 0) return this._text ||
'';
+ return this.childNodes.map(function (c) { return
c.textContent; }).join('');
+ }
+ };
+ node._text = '';
+ return node;
+}
+
+function textNode(value) {
+ return {
+ nodeType: 3,
+ nodeValue: value == null ? '' : String(value),
+ childNodes: [],
+ get textContent() { return this.nodeValue; }
+ };
+}
+
+const VOID_TAGS = { br: 1, hr: 1, img: 1, input: 1, meta: 1, link: 1, base: 1
};
+
+function parseAttrs(raw, node) {
+ if (!raw) return;
+ const re = /([:@\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
+ let m;
+ while ((m = re.exec(raw)))
+ node.setAttribute(m[1], m[2] != null ? m[2] : (m[3] != null ?
m[3] : (m[4] != null ? m[4] : '')));
+}
+
+function parseTestHtml(html) {
+ const root = el('div');
+ const stack = [root];
+ const re = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)\/?>|([^<]+)/g;
+ let m;
+ while ((m = re.exec(html))) {
+ if (m[3] != null) {
+ stack[stack.length - 1].appendChild(textNode(m[3]));
+ continue;
+ }
+ const name = m[1];
+ const closing = html.charAt(m.index + 1) === '/';
+ if (closing) {
+ if (stack.length > 1) stack.pop();
+ continue;
+ }
+ const node = el(name);
+ parseAttrs(m[2], node);
+ stack[stack.length - 1].appendChild(node);
+ const selfClosing = VOID_TAGS[name.toLowerCase()] ||
/\/\s*$/.test(m[2] || '');
+ if (!selfClosing) stack.push(node);
+ }
+ return root;
+}
+
+function DOMParser() {}
+DOMParser.prototype.parseFromString = function (str) {
+ return { body: { firstChild: parseTestHtml(str) } };
+};
+
const document = {
readyState: 'loading',
addEventListener: function () {},
querySelectorAll: function () { return []; },
querySelector: function () { return null; },
getElementById: function () { return null; },
- createElement: function () {
- return {
- setAttribute: function () {},
- appendChild: function () {},
- querySelector: function () { return null; },
- querySelectorAll: function () { return []; }
- };
- },
+ createElement: function (tag) { return el(tag); },
+ createTextNode: function (v) { return textNode(v); },
body: { appendChild: function () {}, querySelectorAll: function () {
return []; } }
};
-const window = { document: document, console: console, jQuery: undefined };
-const sandbox = { window: window, document: document, console: console };
+const window = { document: document, console: console, jQuery: undefined,
DOMParser: DOMParser };
+const sandbox = { window: window, document: document, console: console,
DOMParser: DOMParser };
vm.runInNewContext(fs.readFileSync(path.resolve(viewsJsPath), 'utf8'),
sandbox, { filename: 'juneau-views.js' });
const NS = window.JuneauViews;
@@ -111,6 +185,51 @@ out.fill_num = notes.textContent;
out.fill_missing = extra.textContent;
out.fill_xssNotInterpreted = title.textContent === xss;
+function markdownSlot() {
+ const s = el('div');
+ s.attrs['data-juneau-field'] = 'body';
+ s.attrs['data-juneau-field-format'] = 'markdown';
+ return s;
+}
+function collectTags(node, acc) {
+ if (!node || node.nodeType !== 1) return acc;
+ acc.push(node.tagName);
+ for (let i = 0; i < node.childNodes.length; i++)
collectTags(node.childNodes[i], acc);
+ return acc;
+}
+function findTag(node, tag, list) {
+ if (!node || node.nodeType !== 1) return list;
+ if (node.tagName === tag) list.push(node);
+ for (let i = 0; i < node.childNodes.length; i++)
findTag(node.childNodes[i], tag, list);
+ return list;
+}
+const mdSlot = markdownSlot();
+const mdWrap = {
+ querySelectorAll: function (sel) {
+ if (sel === '[data-juneau-field]') return [mdSlot];
+ return [];
+ }
+};
+I.fillDetailSlots(mdWrap, { body:
+ '<p>ok</p><script>alert(1)</script><p><a
href="javascript:alert(1)">x</a></p>'
+ + '<p><a href="https://ok">y</a></p><p><img src=x
onerror="alert(1)"></p>'
+});
+const mdTags = collectTags(mdSlot, []);
+out.md_tags = mdTags.join(',');
+out.md_hasScript = mdTags.indexOf('SCRIPT') >= 0;
+out.md_hasImg = mdTags.indexOf('IMG') >= 0;
+const anchors = findTag(mdSlot, 'A', []);
+out.md_jsHref = anchors.some(function (a) { return
String(a.getAttribute('href') || '').indexOf('javascript:') >= 0; });
+out.md_httpsHref = anchors.some(function (a) { return a.getAttribute('href')
=== 'https://ok'; });
+out.md_textHasOk = mdSlot.textContent.indexOf('ok') >= 0;
+out.md_textHasX = mdSlot.textContent.indexOf('x') >= 0;
+out.md_textHasY = mdSlot.textContent.indexOf('y') >= 0;
+out.md_textHasAlert = mdSlot.textContent.indexOf('alert(1)') >= 0;
+out.href_js = I.isSafeMarkdownHref('javascript:alert(1)');
+out.href_https = I.isSafeMarkdownHref('https://x');
+out.href_data = I.isSafeMarkdownHref('data:text/html,x');
+out.hasFillMarkdown = typeof I.fillMarkdownSlot === 'function';
+
function btn(id) {
return { attrs: { 'data-juneau-action': id }, disabled: false, hidden:
false,
getAttribute: function (k) { return this.attrs[k]; } };