Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[JENKINS-60866] Make st:bind tag and JavaScript proxy work without inline JS #385

Merged
merged 31 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8c67b73
[JENKINS-60866] Make st:bind tag work without inline JS
daniel-beck Jul 16, 2022
6e13cf8
Also have a separate script file if the bound object is null
daniel-beck Jul 16, 2022
63c818e
Add support for JavaScript proxy
daniel-beck Jul 17, 2022
0a41330
Make this work for SECURITY-595 in Jenkins
daniel-beck Jul 17, 2022
6298feb
Delete unused code
daniel-beck Jul 17, 2022
d9e6ea2
Merge remote-tracking branch 'stapler/master' into csp
daniel-beck Aug 2, 2022
522da35
Merge branch 'jenkinsci:master' into csp
daniel-beck Aug 10, 2022
09335c5
Merge remote-tracking branch 'origin/master' into csp
basil Aug 25, 2022
5f5dd3c
Merge branch 'master' into csp
daniel-beck May 30, 2023
c5b0f60
Merge branch 'master' into csp
daniel-beck Aug 11, 2023
7d73b0b
Merge remote-tracking branch 'origin/csp' into csp
daniel-beck Aug 11, 2023
5db6eea
As requested, don't document when this was deprecated
daniel-beck Aug 14, 2023
5887e70
Fix typo
daniel-beck Aug 14, 2023
09b893a
Make WithWellKnownURL with with CSP-compliant Stapler proxy
daniel-beck Aug 15, 2023
7d53ff7
Merge branch 'master' into csp
daniel-beck Aug 15, 2023
a7d1323
Rename "methods" to (JavaScript)UrlNames to reduce ambiguity
daniel-beck Aug 16, 2023
7bc33e0
Remove obsolete TODO
daniel-beck Aug 16, 2023
34d7e67
Fix "/null" restOfPath, note behavior with unexpected branch
daniel-beck Aug 21, 2023
16f23e3
Restrict identifiers further, allow fallback with unsafe var names
daniel-beck Aug 22, 2023
e4e11ed
Merge branch 'master' into csp
daniel-beck Aug 25, 2023
f67812c
Merge branch 'master' into csp
daniel-beck Sep 20, 2023
84a9ecd
Merge branch 'master' into csp
daniel-beck Sep 25, 2023
742f867
Merge branch 'master' into csp
daniel-beck Oct 6, 2023
81b96c0
Merge branch 'master' into csp
daniel-beck Oct 12, 2023
2ed4a53
Merge branch 'master' into csp
daniel-beck Nov 1, 2023
43e07ad
Merge branch 'master' into csp
daniel-beck Dec 15, 2023
7137f08
Merge branch 'master' into csp
daniel-beck Jan 3, 2024
377842b
Merge branch 'master' into csp
daniel-beck Feb 12, 2024
70afd88
Simplify string joining
daniel-beck Feb 12, 2024
0d1c34f
Javadoc improvements
daniel-beck Feb 13, 2024
dbb41c6
More Javadoc fixes
daniel-beck Feb 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/src/main/java/org/kohsuke/stapler/RequestImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.jvnet.tiger_types.Lister;
import org.kohsuke.stapler.bind.Bound;
import org.kohsuke.stapler.bind.BoundObjectTable;
import org.kohsuke.stapler.lang.Klass;
import org.kohsuke.stapler.lang.MethodRef;
Expand Down Expand Up @@ -183,6 +184,12 @@ public String createJavaScriptProxy(Object toBeExported) {
return getBoundObjectTable().bind(toBeExported).getProxyScript();
}

@Override
public RenderOnDemandParameters createJavaScriptProxyParameters(Object toBeExported) {
final Bound bound = getBoundObjectTable().bind(toBeExported);
return new RenderOnDemandParameters("makeStaplerProxy", bound.getURL(), getWebApp().getCrumbIssuer().issueCrumb(), bound.getBoundJavaScriptUrlNames());
}

@Override
public Stapler getStapler() {
return stapler;
Expand Down
31 changes: 31 additions & 0 deletions core/src/main/java/org/kohsuke/stapler/StaplerRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

package org.kohsuke.stapler;

import java.util.Set;
import org.apache.commons.beanutils.BeanUtils;
import org.apache.commons.beanutils.ConvertUtils;
import org.apache.commons.fileupload.FileItem;
Expand Down Expand Up @@ -515,6 +516,36 @@ public interface StaplerRequest extends HttpServletRequest {
* a proxy on the client side.
*
* Short cut for {@code getBoundObjectTable().bind(toBeExported).getProxyScript()}
*
* @deprecated Use {@link #createJavaScriptProxyParameters(Object)} and invoke {@code makeStaplerProxy} yourself.
*/
@Deprecated
String createJavaScriptProxy(Object toBeExported);

/**
* Return value of {@link #createJavaScriptProxyParameters(Object)}
*/
final class RenderOnDemandParameters {
public final String proxyMethod;
public final String url;
public final String crumb;
public final Set<String> urlNames;

public RenderOnDemandParameters(String proxyMethod, String url, String crumb, Set<String> urlNames) {
this.proxyMethod = proxyMethod;
this.url = url;
this.crumb = crumb;
this.urlNames = urlNames;
}

public String getUrlNames() {
return String.join(",", urlNames);
}
}

/**
* Exports the given Java object as a JavaScript proxy and returns the parameters needed to call
* {@code makeStaplerProxy}.
*/
RenderOnDemandParameters createJavaScriptProxyParameters(Object toBeExported);
}
92 changes: 68 additions & 24 deletions core/src/main/java/org/kohsuke/stapler/bind/Bound.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@

package org.kohsuke.stapler.bind;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.MetaClass;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.WebApp;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Collection;
import java.util.Set;

/**
* Handles to the object bound via {@link BoundObjectTable}.
Expand Down Expand Up @@ -63,34 +65,76 @@ public abstract class Bound implements HttpResponse {
* talks back to the bound object that this handle represents.
*/
public final String getProxyScript() {
StringBuilder buf = new StringBuilder("makeStaplerProxy('").append(getURL()).append("','").append(
WebApp.getCurrent().getCrumbIssuer().issueCrumb()
).append("',[");
return getProxyScript(getURL(), getTarget().getClass());
}

/**
* Returns the URL to the proxy script for this {@link org.kohsuke.stapler.bind.Bound}
* @param variableName the variable to assign to the bound object
* @param bound the bound object, or {@code null} if none.
* @return
daniel-beck marked this conversation as resolved.
Show resolved Hide resolved
*/
public static String getProxyScriptURL(String variableName, Bound bound) {
if (bound == null) {
return Stapler.getCurrentRequest().getContextPath() + BoundObjectTable.SCRIPT_PREFIX + "/null?var=" + variableName;
} else {
return bound.getProxyScriptURL(variableName);
}
}

/**
* Returns the URL for the standalone proxy script.
* @param variableName the name of the JS variable to assign
* @return
daniel-beck marked this conversation as resolved.
Show resolved Hide resolved
*/
public final String getProxyScriptURL(String variableName) {
final String methodsList = String.join(",", getBoundJavaScriptUrlNames(getTarget().getClass()));
// The URL looks like it has some redundant elements, but only if it's not a WithWellKnownURL
return Stapler.getCurrentRequest().getContextPath() + BoundObjectTable.SCRIPT_PREFIX + getURL() + "?var=" + variableName + "&methods=" + methodsList;
}

boolean first=true;
for (Method m : getTarget().getClass().getMethods()) {
Collection<String> names;
private static Set<String> getBoundJavaScriptUrlNames(Class<?> clazz) {
Set<String> names = new HashSet<>();
for (Method m : clazz.getMethods()) {
if (m.getName().startsWith("js")) {
names = Set.of(camelize(m.getName().substring(2)));
names.add(camelize(m.getName().substring(2)));
} else {
JavaScriptMethod a = m.getAnnotation(JavaScriptMethod.class);
if (a!=null) {
names = Arrays.asList(a.name());
if (names.isEmpty())
names = Set.of(m.getName());
} else
continue;
}

for (String n : names) {
if (first) first = false;
else buf.append(',');
buf.append('\'').append(n).append('\'');
if (a != null) {
if (a.name().length == 0) {
names.add(m.getName());
} else {
names.addAll(Arrays.asList(a.name()));
}
}
}
}
buf.append("])");

return buf.toString();
return names;
}

/**
* Returns a list of all JS bound methods of the target's type.
* @return a list of all JS bound methods of the target's type
*/
public final Set<String> getBoundJavaScriptUrlNames() {
return getBoundJavaScriptUrlNames(getTarget().getClass());
}

public static String getProxyScript(String url, Class<?> clazz) {
return getProxyScript(url, getBoundJavaScriptUrlNames(clazz).toArray(String[]::new));
}

/**
* Returns the Stapler proxy script for the specified URL and method names
*
* @param url URL to proxied object
* @param methods list of method names
* @return
*/
public static String getProxyScript(String url, String[] methods) {
final String crumb = WebApp.getCurrent().getCrumbIssuer().issueCrumb();
final String methodNamesList = Arrays.stream(methods).sorted().map(it -> "'" + it + "'").collect(Collectors.joining(","));
daniel-beck marked this conversation as resolved.
Show resolved Hide resolved
return "makeStaplerProxy('" + url + "','" + crumb + "',[" + methodNamesList + "])";
}

private static String camelize(String name) {
Expand Down
93 changes: 93 additions & 0 deletions core/src/main/java/org/kohsuke/stapler/bind/BoundObjectTable.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@
package org.kohsuke.stapler.bind;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.logging.Level;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.Ancestor;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.Stapler;
import org.kohsuke.stapler.StaplerFallback;
import org.kohsuke.stapler.StaplerRequest;
Expand All @@ -51,6 +56,93 @@
* @author Kohsuke Kawaguchi
*/
public class BoundObjectTable implements StaplerFallback {

public static boolean isValidJavaScriptIdentifier(String variableName) {
// Ultimately this will be used as a JS identifier, so we need (a subset of) what's valid there.
// The primary purpose of this check however is to prevent injection attacks.
return variableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$");
}

public static boolean isValidJavaIdentifier(String name) {
if (name == null || StringUtils.isBlank(name)) {
return false;
}
if (!Character.isJavaIdentifierStart(name.charAt(0)) || Character.codePointAt(name, 0) > 255) {
return false;
}
// Limit characters to legal Java identifier parts in Latin-1 that aren't ignorable
return name.substring(1)
.chars()
.allMatch(it -> Character.isJavaIdentifierPart(it) && !Character.isIdentifierIgnorable(it) && it < 256);
}

/**
* This serves the script content for a bound object. Support CSP-compatible st:bind and similar methods of making
* objects accessible to JS.
*
* @param req
* @param rsp
* @param var the variable name to assign the Stapler proxy to
* @param methods the list of methods (needed for {@link WithWellKnownURL})
* @throws IOException
*/
public void doScript(StaplerRequest req, StaplerResponse rsp, @QueryParameter String var, @QueryParameter String methods) throws IOException {
final String boundUrl = req.getRestOfPath();

if (var == null) {
return;
}

if (!isValidJavaScriptIdentifier(var)) {
LOGGER.log(Level.FINE, () -> "Rejecting invalid JavaScript identifier: " + var);
return;
}
daniel-beck marked this conversation as resolved.
Show resolved Hide resolved

rsp.setContentType("application/javascript");
final PrintWriter writer = rsp.getWriter();

if ("/null".equals(boundUrl)) {
/* This is the case when the object was null in the first place */
writer.append(var).append(" = null;");
return;
}

final String script;

/* If this is not a WithWellKnownURL, look UUID up in bound object table and return null if not found. */
final String contextAndPrefix = Stapler.getCurrentRequest().getContextPath() + PREFIX;
if (boundUrl.startsWith(contextAndPrefix)) {
final String id = boundUrl.replace(contextAndPrefix, "");
final Table table = resolve(false);
if (table == null) {
rsp.sendError(404);
return;
}
Object object = table.resolve(id);
if (object == null) {
/* Support null bound objects */
writer.append(var).append(" = null;");
return;
}
script = Bound.getProxyScript(boundUrl, object.getClass());
} else {
if (methods == null) {
/* This will result in an empty file rather than an explicit null assignment,
but it's unexpected to have a WithWellKnownURL without ?methods query parameter. */
return;
}
final String[] methodsArray = methods.split(",");
if (Arrays.stream(methodsArray).anyMatch(it -> !isValidJavaIdentifier(it))) {
LOGGER.log(Level.FINE, () -> "Rejecting method list that includes an invalid Java identifier: " + methods);
// TODO Alternatively, filter out invalid method names and only include valid ones.

Check warning on line 137 in core/src/main/java/org/kohsuke/stapler/bind/BoundObjectTable.java

View check run for this annotation

ci.jenkins.io / Open Tasks Scanner

TODO

NORMAL: Alternatively, filter out invalid method names and only include valid ones.
// Could help with non-malicious but encoding related issues
return;
}
script = Bound.getProxyScript(boundUrl, methodsArray);
}
writer.append(var).append(" = ").append(script).append(";");
}

@Override
public Table getStaplerFallback() {
return resolve(false);
Expand Down Expand Up @@ -264,6 +356,7 @@
}

public static final String PREFIX = "/$stapler/bound/";
static final String SCRIPT_PREFIX = "/$stapler/bound/script";

/**
* True to activate debug logging of session fragments.
Expand Down
58 changes: 45 additions & 13 deletions jelly/src/main/java/org/kohsuke/stapler/jelly/BindTag.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@

package org.kohsuke.stapler.jelly;

import edu.umd.cs.findbugs.annotations.CheckForNull;
import org.apache.commons.jelly.JellyTagException;
import org.apache.commons.jelly.XMLOutput;
import org.jvnet.maven.jellydoc.annotation.NoContent;
import org.jvnet.maven.jellydoc.annotation.Required;
import org.kohsuke.stapler.WebApp;
import org.kohsuke.stapler.bind.Bound;
import org.kohsuke.stapler.bind.BoundObjectTable;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

/**
* Binds a server-side object to client side so that JavaScript can call into server.
Expand Down Expand Up @@ -68,25 +71,54 @@ public void doTag(XMLOutput out) throws JellyTagException {
a.doTag(out);

try {
String expr;
if (javaObject==null) {
expr = "null";
if (javaObject == null) {
if (varName == null) {
// Legacy mode if no 'var' is specified and we bound 'null': Write 'null' expression inline, without <script> wrapper.
out.write("null");
} else {
// Modern: Write a script tag whose 'src' points to DataBoundTable#doScript, with a special suffix indicating the null bound object.
writeScriptTag(out, null);
}
} else {
Bound h = WebApp.getCurrent().boundObjectTable.bind(javaObject);
expr = h.getProxyScript();
}

if (varName==null) {
// this mode (of writing just the expression) needs to be used with caution because
// the adjunct tag above might produce <script> tag.
out.write(expr);
} else {
out.startElement("script");
out.write(varName + "=" + expr + ";");
out.endElement("script");
if (varName == null) {
// Legacy mode if no 'var' is specified: Write the expression inline, without <script> wrapper.
// Doing this is deprecated as it cannot be done with Content-Security-Policy unless 'unsafe-inline' is allowed.
// Additionally, this mode needs to be used with caution because the adjunct tag above might produce a <script> tag.
out.write(h.getProxyScript());
} else if (!BoundObjectTable.isValidJavaScriptIdentifier(varName)) {
// Legacy mode if 'var' is not a safe variable name: Write the expression inline within <script> wrapper.
// Doing this is deprecated as it cannot be done with Content-Security-Policy unless 'unsafe-inline' is allowed.
out.startElement("script");
out.write(varName + "=" + h.getProxyScript() + ";");
out.endElement("script");
} else {
// Modern: Write a script tag whose 'src' points to DataBoundTable#doScript
writeScriptTag(out, h);
}
}
} catch (SAXException e) {
throw new JellyTagException(e);
}
}

/**
* Writes a &lt;script&gt; tag whose {@code src} attribute points to
daniel-beck marked this conversation as resolved.
Show resolved Hide resolved
* {@link BoundObjectTable#doScript(org.kohsuke.stapler.StaplerRequest, org.kohsuke.stapler.StaplerResponse, String, String)}.
* @param out XML output
* @param bound Wrapper for the bound object
* @throws SAXException if something goes wrong writing XML
*/
private void writeScriptTag(XMLOutput out, @CheckForNull Bound bound) throws SAXException {
final AttributesImpl attributes = new AttributesImpl();
if (bound == null) {
attributes.addAttribute("", "src", "src", "", Bound.getProxyScriptURL(varName, null));
} else {
attributes.addAttribute("", "src", "src", "", bound.getProxyScriptURL(varName));
}
attributes.addAttribute("", "type", "type", "", "application/javascript");
out.startElement("script", attributes);
out.endElement("script");
}
}