diff --git a/core/pom.xml b/core/pom.xml index 258e26f64f..500685db41 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -39,7 +39,7 @@ THE SOFTWARE. true - 1.254.1 + 1.254.2 2.5.6.SEC03 2.4.12 diff --git a/core/src/main/java/hudson/FilePath.java b/core/src/main/java/hudson/FilePath.java index 00d453f794..ea5177037e 100644 --- a/core/src/main/java/hudson/FilePath.java +++ b/core/src/main/java/hudson/FilePath.java @@ -3280,5 +3280,60 @@ public final class FilePath implements Serializable { return IOUtils.mkdirs(dir); } + /** + * Check if the relative child is really a descendant after symlink resolution if any. + * + * TODO un-restrict it in a weekly after the patch + */ + @Restricted(NoExternalUse.class) + public boolean isDescendant(@Nonnull String potentialChildRelativePath) throws IOException, InterruptedException { + return act(new IsDescendant(potentialChildRelativePath)); + } + + private class IsDescendant extends SecureFileCallable { + private static final long serialVersionUID = 1L; + private String potentialChildRelativePath; + + private IsDescendant(@Nonnull String potentialChildRelativePath){ + this.potentialChildRelativePath = potentialChildRelativePath; + } + + @Override + public Boolean invoke(@Nonnull File parentFile, @Nonnull VirtualChannel channel) throws IOException, InterruptedException { + if (new File(potentialChildRelativePath).isAbsolute()) { + throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath); + } + + Path parent = parentFile.getAbsoluteFile().toPath().normalize(); + + String remainingPath = potentialChildRelativePath; + File currentFile = parentFile; + while (!remainingPath.isEmpty()) { + File directChild = this.getDirectChild(currentFile, remainingPath); + File childUsingFullPath = new File(currentFile, remainingPath); + remainingPath = childUsingFullPath.getAbsolutePath().substring(directChild.getAbsolutePath().length()); + + File childFileSymbolic = Util.resolveSymlinkToFile(directChild); + if (childFileSymbolic == null) { + currentFile = directChild; + } else { + currentFile = childFileSymbolic; + } + } + + //TODO could be refactored using Util#isDescendant(File, File) from 2.80+ + Path child = currentFile.getAbsoluteFile().toPath().normalize(); + return child.startsWith(parent); + } + + private @CheckForNull File getDirectChild(File parentFile, String childPath){ + File current = new File(parentFile, childPath); + while (current != null && !parentFile.equals(current.getParentFile())) { + current = current.getParentFile(); + } + return current; + } + } + private static final SoloFilePathFilter UNRESTRICTED = SoloFilePathFilter.wrap(FilePathFilter.UNRESTRICTED); } diff --git a/core/src/main/java/hudson/model/AbstractItem.java b/core/src/main/java/hudson/model/AbstractItem.java index aced1805e5..92303466d0 100644 --- a/core/src/main/java/hudson/model/AbstractItem.java +++ b/core/src/main/java/hudson/model/AbstractItem.java @@ -58,6 +58,7 @@ import jenkins.util.xml.XMLUtils; import org.apache.tools.ant.taskdefs.Copy; import org.apache.tools.ant.types.FileSet; +import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.StaplerProxy; import org.kohsuke.stapler.WebMethod; import org.kohsuke.stapler.export.Exported; @@ -97,6 +98,8 @@ import javax.xml.transform.stream.StreamSource; import static hudson.model.queue.Executables.getParentOf; import hudson.model.queue.SubTask; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + import org.apache.commons.io.FileUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @@ -938,6 +941,14 @@ public abstract class AbstractItem extends Actionable implements Item, HttpDelet @Restricted(NoExternalUse.class) public Object getTarget() { if (!SKIP_PERMISSION_CHECK) { + if (!getACL().hasPermission(Item.DISCOVER)) { + // work around Stapler bug when returning null from getTarget() + try { + Stapler.getCurrentResponse().sendError(SC_NOT_FOUND); // send same response body Stapler would send + } catch (IOException ex) { + throw HttpResponses.notFound(); + } + } getACL().checkPermission(Item.READ); } return this; diff --git a/core/src/main/java/hudson/model/Api.java b/core/src/main/java/hudson/model/Api.java index b3ce84dfff..024d66869d 100644 --- a/core/src/main/java/hudson/model/Api.java +++ b/core/src/main/java/hudson/model/Api.java @@ -135,7 +135,19 @@ public class Api extends AbstractModelObject { XPath comp = dom.createXPath(xpath); comp.setFunctionContext(functionContext); List list = comp.selectNodes(dom); + if (wrapper!=null) { + // check if the wrapper is a valid entity name + // First position: letter or underscore + // Other positions: \w (letter, number, underscore), dash or dot + String validNameRE = "^[a-zA-Z_][\\w-\\.]*$"; + + if(!wrapper.matches(validNameRE)) { + rsp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + rsp.getWriter().print(Messages.Api_WrapperParamInvalid()); + return; + } + Element root = DocumentFactory.getInstance().createElement(wrapper); for (Object o : list) { if (o instanceof String) { diff --git a/core/src/main/java/hudson/model/Descriptor.java b/core/src/main/java/hudson/model/Descriptor.java index d51edc0c39..124abdc126 100644 --- a/core/src/main/java/hudson/model/Descriptor.java +++ b/core/src/main/java/hudson/model/Descriptor.java @@ -39,6 +39,7 @@ import hudson.views.ListViewColumn; import jenkins.model.GlobalConfiguration; import jenkins.model.GlobalConfigurationCategory; import jenkins.model.Jenkins; +import jenkins.security.RedactSecretJsonInErrorMessageSanitizer; import jenkins.util.io.OnMaster; import net.sf.json.JSONArray; import net.sf.json.JSONObject; @@ -601,7 +602,7 @@ public abstract class Descriptor> implements Saveable, } catch (NoSuchMethodException e) { throw new AssertionError(e); // impossible } catch (InstantiationException | IllegalAccessException | RuntimeException e) { - throw new Error("Failed to instantiate "+clazz+" from "+formData,e); + throw new Error("Failed to instantiate "+clazz+" from "+RedactSecretJsonInErrorMessageSanitizer.INSTANCE.sanitize(formData),e); } } diff --git a/core/src/main/java/hudson/model/FileParameterValue.java b/core/src/main/java/hudson/model/FileParameterValue.java index 85bfb04387..f911927285 100644 --- a/core/src/main/java/hudson/model/FileParameterValue.java +++ b/core/src/main/java/hudson/model/FileParameterValue.java @@ -36,6 +36,7 @@ import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.nio.file.Files; import java.nio.file.InvalidPathException; +import java.nio.file.Path; import javax.servlet.ServletException; import org.apache.commons.fileupload.FileItem; @@ -45,6 +46,8 @@ import org.apache.commons.fileupload.util.FileItemHeadersImpl; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang.StringUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; @@ -61,6 +64,16 @@ import org.kohsuke.stapler.StaplerResponse; * @author Kohsuke Kawaguchi */ public class FileParameterValue extends ParameterValue { + private static final String FOLDER_NAME = "fileParameters"; + + /** + * Escape hatch for SECURITY-1074, fileParameter used to escape their expected folder. + * It's not recommended to enable for security reasons. That option is only present for backward compatibility. + */ + @Restricted(NoExternalUse.class) + public static /* Script Console modifiable */ boolean ALLOW_FOLDER_TRAVERSAL_OUTSIDE_WORKSPACE = + Boolean.getBoolean(FileParameterValue.class.getName() + ".allowFolderTraversalOutsideWorkspace"); + private transient final FileItem file; /** @@ -70,6 +83,9 @@ public class FileParameterValue extends ParameterValue { /** * Overrides the location in the build to place this file. Initially set to {@link #getName()} + * The location could be directly the filename or also a hierarchical path. + * The intermediate folders will be created automatically. + * Take care that no escape from the current directory is allowed and will result in the failure of the build. */ private String location; @@ -142,7 +158,16 @@ public class FileParameterValue extends ParameterValue { public Environment setUp(AbstractBuild build, Launcher launcher, BuildListener listener) throws IOException, InterruptedException { if (!StringUtils.isEmpty(location) && !StringUtils.isEmpty(file.getName())) { listener.getLogger().println("Copying file to "+location); - FilePath locationFilePath = build.getWorkspace().child(location); + FilePath ws = build.getWorkspace(); + if (ws == null) { + throw new IllegalStateException("The workspace should be created when setUp method is called"); + } + if (!ALLOW_FOLDER_TRAVERSAL_OUTSIDE_WORKSPACE && !ws.isDescendant(location)) { + listener.error("Rejecting file path escaping base directory with relative path: " + location); + // force the build to fail + return null; + } + FilePath locationFilePath = ws.child(location); locationFilePath.getParent().mkdirs(); locationFilePath.copyFrom(file); locationFilePath.copyTo(new FilePath(getLocationUnderBuild(build))); @@ -204,6 +229,18 @@ public class FileParameterValue extends ParameterValue { if (("/" + originalFileName).equals(request.getRestOfPath())) { AbstractBuild build = (AbstractBuild)request.findAncestor(AbstractBuild.class).getObject(); File fileParameter = getLocationUnderBuild(build); + + if (!ALLOW_FOLDER_TRAVERSAL_OUTSIDE_WORKSPACE) { + File fileParameterFolder = getFileParameterFolderUnderBuild(build); + + //TODO can be replaced by Util#isDescendant in 2.80+ + Path child = fileParameter.getAbsoluteFile().toPath().normalize(); + Path parent = fileParameterFolder.getAbsoluteFile().toPath().normalize(); + if (!child.startsWith(parent)) { + throw new IllegalStateException("The fileParameter tried to escape the expected folder: " + location); + } + } + if (fileParameter.isFile()) { try (InputStream data = Files.newInputStream(fileParameter.toPath())) { long lastModified = fileParameter.lastModified(); @@ -227,7 +264,11 @@ public class FileParameterValue extends ParameterValue { * @return the location to store the file parameter */ private File getLocationUnderBuild(AbstractBuild build) { - return new File(build.getRootDir(), "fileParameters/" + location); + return new File(getFileParameterFolderUnderBuild(build), location); + } + + private File getFileParameterFolderUnderBuild(AbstractBuild build){ + return new File(build.getRootDir(), FOLDER_NAME); } /** diff --git a/core/src/main/java/hudson/model/Run.java b/core/src/main/java/hudson/model/Run.java index 3fc6905f50..5fdff73308 100644 --- a/core/src/main/java/hudson/model/Run.java +++ b/core/src/main/java/hudson/model/Run.java @@ -95,6 +95,8 @@ import java.util.Map; import java.util.Set; import java.util.logging.Level; import static java.util.logging.Level.*; +import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; + import java.util.logging.Logger; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; @@ -123,8 +125,10 @@ import org.apache.commons.lang.ArrayUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerProxy; import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; import org.kohsuke.stapler.export.Exported; @@ -144,7 +148,7 @@ import org.kohsuke.stapler.interceptor.RequirePOST; */ @ExportedBean public abstract class Run ,RunT extends Run> - extends Actionable implements ExtensionPoint, Comparable, AccessControlled, PersistenceRoot, DescriptorByNameOwner, OnMaster { + extends Actionable implements ExtensionPoint, Comparable, AccessControlled, PersistenceRoot, DescriptorByNameOwner, OnMaster, StaplerProxy { /** * The original {@link Queue.Item#getId()} has not yet been mapped onto the {@link Run} instance. @@ -2570,6 +2574,31 @@ public abstract class Run ,RunT extends Run retrieveRedactedKeys(JSONObject jsonObject) { + Set redactedKeySet = new HashSet<>(); + if (jsonObject.has(REDACT_KEY)) { + Object value = jsonObject.get(REDACT_KEY); + if (value instanceof JSONArray) { + for (Object o : jsonObject.getJSONArray(REDACT_KEY)) { + if (o instanceof String) { + redactedKeySet.add((String) o); + } else { + // array, object, null, number, boolean + LOGGER.log(Level.WARNING, "Unsupported type " + o.getClass().getName() + " for " + REDACT_KEY + ", please use either a single String value or an Array"); + } + } + } else if (value instanceof String) { + redactedKeySet.add((String) value); + } else { + // object, null, number, boolean + LOGGER.log(Level.WARNING, "Unsupported type " + value.getClass().getName() + " for " + REDACT_KEY + ", please use either a single String value or an Array"); + } + } + return redactedKeySet; + } + + private Object copyAndSanitize(Object value) { + if (value instanceof JSONObject) { + return copyAndSanitizeObject((JSONObject) value); + } else if (value instanceof JSONArray) { + return copyAndSanitizeArray((JSONArray) value); + } else { + // string, null, number, boolean + return value; + } + } + + @SuppressWarnings("unchecked") + private JSONObject copyAndSanitizeObject(JSONObject jsonObject) { + Set redactedKeySet = retrieveRedactedKeys(jsonObject); + JSONObject result = new JSONObject(); + + jsonObject.keySet().forEach(keyObject -> { + String key = keyObject.toString(); + if (redactedKeySet.contains(key)) { + result.accumulate(key, REDACT_VALUE); + } else { + Object value = jsonObject.get(keyObject); + result.accumulate(key, copyAndSanitize(value)); + } + }); + + return result; + } + + private JSONArray copyAndSanitizeArray(JSONArray jsonArray) { + JSONArray result = new JSONArray(); + + jsonArray.forEach(value -> + result.add(copyAndSanitize(value)) + ); + + return result; + } +} diff --git a/core/src/main/resources/hudson/model/Messages.properties b/core/src/main/resources/hudson/model/Messages.properties index a8f1d9a5aa..191592641f 100644 --- a/core/src/main/resources/hudson/model/Messages.properties +++ b/core/src/main/resources/hudson/model/Messages.properties @@ -87,6 +87,7 @@ AbstractProject.LabelLink=Label {1} is serviced by {3,choic Api.MultipleMatch=XPath "{0}" matched {1} nodes. \ Create XPath that only matches one, or use the "wrapper" query parameter to wrap them all under a root element. Api.NoXPathMatch=XPath {0} didn\u2019t match +Api.WrapperParamInvalid=The wrapper parameter can only contain alphanumeric characters or dash/dot/underscore. The first character has to be a letter or underscore. BallColor.Aborted=Aborted BallColor.Disabled=Disabled diff --git a/core/src/main/resources/hudson/model/Messages_es.properties b/core/src/main/resources/hudson/model/Messages_es.properties index 6d82b5d1aa..b61a753a64 100644 --- a/core/src/main/resources/hudson/model/Messages_es.properties +++ b/core/src/main/resources/hudson/model/Messages_es.properties @@ -48,6 +48,7 @@ AbstractProject.ExtendedReadPermission.Description=\ Api.MultipleMatch=XPath "{0}" encontr\u00f3 coincidencias en {1} nodos. \ Crea una expresi\u00f3n XPath que s\u00f3lo encuentre uno, o utiliza el par\u00e1metro "wraper" para que todos los nodos se agrupen bajo un elemento. Api.NoXPathMatch=XPath {0} no encontr\u00f3 nada +Api.WrapperParamInvalid=El par\u00E1metro wrapper s\u00F3lo puede contener caracteres alfanum\u00E9ricos o guiones/puntos/subrayado. El primer car\u00E1cter debe ser una letra o subrayado. BallColor.Aborted=Abortado BallColor.Disabled=Desactivado diff --git a/core/src/main/resources/hudson/model/Messages_fr.properties b/core/src/main/resources/hudson/model/Messages_fr.properties index d1ded2fc0f..7b65394ff0 100644 --- a/core/src/main/resources/hudson/model/Messages_fr.properties +++ b/core/src/main/resources/hudson/model/Messages_fr.properties @@ -41,6 +41,7 @@ AbstractProject.WorkspacePermission.Description=\ Api.MultipleMatch=Le XPath "{0}" correspond \u00e0 {1} noeud(s). \ Merci de fournir un XPath qui ne correspond qu''\u00e0 un seul noeud, ou utilisez le param\u00e8tre de requ\u00e8te "wrapper" pour les encapsuler tous dans un \u00e9l\u00e9ment racine. Api.NoXPathMatch=Pas de correspondance avec le XPath {0} +Api.WrapperParamInvalid=Le param\u00E8tre wrapper ne doit contenir que des caract\u00E8res alphanum\u00E9riques ainsi que des points / tirets bas / traits d''union, et finalement le premier caract\u00E8re doit \u00EAtre une lettre ou un trait d''union. BallColor.Aborted=Annul\u00e9 BallColor.Disabled=D\u00e9sactiv\u00e9 @@ -178,3 +179,4 @@ MyViewsProperty.GlobalAction.DisplayName=Mes vues ManageJenkinsAction.DisplayName=Administrer Jenkins ParametersDefinitionProperty.DisplayName=Ce build a des paramètres + diff --git a/core/src/test/java/jenkins/security/RedactSecretJsonInErrorMessageSanitizerTest.java b/core/src/test/java/jenkins/security/RedactSecretJsonInErrorMessageSanitizerTest.java new file mode 100644 index 0000000000..9a975481c2 --- /dev/null +++ b/core/src/test/java/jenkins/security/RedactSecretJsonInErrorMessageSanitizerTest.java @@ -0,0 +1,139 @@ +/* + * The MIT License + * + * Copyright (c) 2018, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package jenkins.security; + +import net.sf.json.JSONObject; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; + +@Issue("SECURITY-765") +public class RedactSecretJsonInErrorMessageSanitizerTest { + @Test + public void noSecrets() throws Exception { + assertRedaction( + "{'a': 1, 'b': '2', 'c': {'c1': 1, 'c2': '2', 'c3': ['3a', '3b']}, 'd': ['4a', {'d1': 1, 'd2': '2'}]}", + "{'a': 1, 'b': '2', 'c': {'c1': 1, 'c2': '2', 'c3': ['3a', '3b']}, 'd': ['4a', {'d1': 1, 'd2': '2'}]}" + ); + } + + @Test + public void simpleWithSecret() throws Exception { + assertRedaction( + "{'a': 'secret', 'b': 'other', '$redact': 'a'}", + "{'a': '[value redacted]', 'b': 'other', '$redact': 'a'}" + ); + } + + @Test + public void singleWithRedactedInArray() throws Exception { + assertRedaction( + "{'a': 'secret', 'b': 'other', '$redact': ['a']}", + "{'a': '[value redacted]', 'b': 'other', '$redact': ['a']}" + ); + } + + @Test + public void objectRedactedAcceptedButNotProcessed() throws Exception { + assertRedaction( + "{'a': 'secret', 'b': 'other', '$redact': {'a': 'a'}}", + "{'a': 'secret', 'b': 'other', '$redact': {'a': 'a'}}" + ); + } + + @Test + public void weirdValuesInRedactedAcceptedButNotProcessed() throws Exception { + assertRedaction( + "{'a': 'secret', 'b': 'other', '$redact': [null, true, false, 1, 2, 'a']}", + "{'a': '[value redacted]', 'b': 'other', '$redact': [null, true, false, 1, 2, 'a']}" + ); + } + + @Test + public void ensureTrueAndOneAsStringAreSupportedAsRedactedKey() throws Exception { + //only null is not supported, as passing 'null' is considered as null + assertRedaction( + "{'true': 'secret1', '1': 'secret3', 'b': 'other', '$redact': ['true', '1']}", + "{'true': '[value redacted]', '1': '[value redacted]', 'b': 'other', '$redact': ['true', '1']}" + ); + } + + @Test + public void redactFullBranch() throws Exception { + assertRedaction( + "{'a': {'s1': 'secret1', 's2': 'secret2', 's3': [1,2,3]}, 'b': [4,5,6], 'c': 'other', '$redact': ['a', 'b']}", + "{'a': '[value redacted]', 'b': '[value redacted]', 'c': 'other', '$redact': ['a', 'b']}" + ); + } + + @Test + public void multipleSecretAtSameLevel() throws Exception { + assertRedaction( + "{'a1': 'secret1', 'a2': 'secret2', 'b': 'other', '$redact': ['a1', 'a2']}", + "{'a1': '[value redacted]', 'a2': '[value redacted]', 'b': 'other', '$redact': ['a1', 'a2']}" + ); + } + + @Test + public void redactedKeyWithoutCorrespondences() throws Exception { + assertRedaction( + "{'a1': 'secret1', 'a2': 'secret2', 'b': 'other', '$redact': ['a0', 'a1', 'a2', 'a3']}", + "{'a1': '[value redacted]', 'a2': '[value redacted]', 'b': 'other', '$redact': ['a0', 'a1', 'a2', 'a3']}" + ); + } + + @Test + public void secretsAtMultipleLevels() throws Exception { + assertRedaction( + "{'a1': 'secret1', 'a2': 'secret2', 'b': 'other', '$redact': ['a1', 'a2'], 'sub': {'c1': 'secret1', 'c2': 'secret2', 'c3': 'other', '$redact': ['c1', 'c2']}}", + "{'a1': '[value redacted]', 'a2': '[value redacted]', 'b': 'other', '$redact': ['a1', 'a2'], 'sub': {'c1': '[value redacted]', 'c2': '[value redacted]', 'c3': 'other', '$redact': ['c1', 'c2']}}" + ); + } + + @Test + public void noInteractionBetweenLevels() throws Exception { + assertRedaction( + "{'a': 'secret', 'b': 'other', 'c': 'other', '$redact': 'a', 'sub': {'a': 'other', 'b': 'secret', 'c': 'other', '$redact': 'b'}}", + "{'a': '[value redacted]', 'b': 'other', 'c': 'other', '$redact': 'a', 'sub': {'a': 'other', 'b': '[value redacted]', 'c': 'other', '$redact': 'b'}}" + ); + } + + @Test + public void deeplyNestedObject() throws Exception { + assertRedaction( + "{'sub': {'arr': ['d1', 2, {'a1': 'other', 'b1':'other', 'c1': 'secret', '$redact': 'c1'}, 4, {'a2': 'other', 'b2': 'other', 'c2': 'secret', '$redact': 'c2'}]}, '$redact': 'b'}", + "{'sub': {'arr': ['d1', 2, {'a1': 'other', 'b1':'other', 'c1': '[value redacted]', '$redact': 'c1'}, 4, {'a2': 'other', 'b2': 'other', 'c2': '[value redacted]', '$redact': 'c2'}]}, '$redact': 'b'}" + ); + } + + private void assertRedaction(String from, String to) { + JSONObject input = JSONObject.fromObject(from.replace('\'', '"')); + JSONObject output = RedactSecretJsonInErrorMessageSanitizer.INSTANCE.sanitize(input); + assertNotSame(output, input); + assertEquals("redaction of " + from, to.replace('\'', '"').replace(" ", ""), + output.toString().replace(" ", "")); + } +} diff --git a/test/src/test/java/hudson/AbstractItemSecurity1114Test.java b/test/src/test/java/hudson/AbstractItemSecurity1114Test.java new file mode 100644 index 0000000000..c6d0e2199e --- /dev/null +++ b/test/src/test/java/hudson/AbstractItemSecurity1114Test.java @@ -0,0 +1,97 @@ +package hudson; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import hudson.model.AbstractItem; +import hudson.model.Item; +import hudson.model.RootAction; +import hudson.security.ACL; +import hudson.security.ACLContext; +import jenkins.model.Jenkins; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.For; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.TestExtension; + +import javax.annotation.CheckForNull; + +public class AbstractItemSecurity1114Test { + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @Issue("SECURITY-1114") + @For(AbstractItem.class) + public void testAccess() throws Exception { + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + MockAuthorizationStrategy authorizationStrategy = new MockAuthorizationStrategy(); + authorizationStrategy.grant(Jenkins.READ).onRoot().toEveryone(); + authorizationStrategy.grant(Item.DISCOVER).everywhere().to("alice"); + authorizationStrategy.grant(Item.READ).everywhere().to("bob"); + j.jenkins.setAuthorizationStrategy(authorizationStrategy); + + j.createFreeStyleProject("myproject"); + + // alice can discover project + JenkinsRule.WebClient wc = j.createWebClient().login("alice"); + try { + wc.goTo("bypass/myproject"); + Assert.fail("expected exception"); + } catch (FailingHttpStatusCodeException e) { + Assert.assertEquals("alice can discover", 403, e.getStatusCode()); + } + + // bob can read project + wc = j.createWebClient().login("bob"); + wc.goTo("bypass/myproject"); // success + + + // carol has no permissions + wc = j.createWebClient().login("carol"); + try { + wc.goTo("bypass/nonexisting"); + Assert.fail("expected exception"); + } catch (FailingHttpStatusCodeException e) { + Assert.assertEquals("carol gets 404 for nonexisting project", 404, e.getStatusCode()); + } + try { + wc.goTo("bypass/myproject"); + Assert.fail("expected exception"); + } catch (FailingHttpStatusCodeException e) { + Assert.assertEquals("carol gets 404 for invisible project", 404, e.getStatusCode()); + } + + } + + @TestExtension + public static class BypassAccess implements RootAction { + public Item getDynamic(String name) { + Item item; + try (ACLContext ctx = ACL.as(ACL.SYSTEM)) { + item = Jenkins.getInstance().getItemByFullName(name); + } + return item; + } + + @CheckForNull + @Override + public String getIconFileName() { + return null; + } + + @CheckForNull + @Override + public String getDisplayName() { + return null; + } + + @CheckForNull + @Override + public String getUrlName() { + return "bypass"; + } + } +} diff --git a/test/src/test/java/hudson/PluginTest_SEC925.java b/test/src/test/java/hudson/PluginSEC925Test.java similarity index 97% rename from test/src/test/java/hudson/PluginTest_SEC925.java rename to test/src/test/java/hudson/PluginSEC925Test.java index f52abce238..6ecf68ae1b 100644 --- a/test/src/test/java/hudson/PluginTest_SEC925.java +++ b/test/src/test/java/hudson/PluginSEC925Test.java @@ -13,7 +13,7 @@ import java.util.List; import java.util.concurrent.Future; //TODO merge it within PluginTest after the security release -public class PluginTest_SEC925 { +public class PluginSEC925Test { @Rule public JenkinsRule r = new JenkinsRule(); diff --git a/test/src/test/java/hudson/model/ApiSecurity1129Test.java b/test/src/test/java/hudson/model/ApiSecurity1129Test.java new file mode 100644 index 0000000000..999eacb43e --- /dev/null +++ b/test/src/test/java/hudson/model/ApiSecurity1129Test.java @@ -0,0 +1,86 @@ +package hudson.model; + +import com.gargoylesoftware.htmlunit.WebResponse; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.xml.sax.SAXException; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +//TODO after the security fix, it could be merged inside ApiTest +public class ApiSecurity1129Test { + @Rule + public JenkinsRule j = new JenkinsRule(); + + /** + * Test the wrapper parameter for the api/xml urls to avoid XSS. + * @throws Exception See {@link #checkWrapperParam(String, Integer, String)} + */ + @Issue("SECURITY-1129") + @Test + public void wrapperXss() throws Exception { + String wrapper = "html%20xmlns=\"http://www.w3.org/1999/xhtml\"> + + + + + + + + + + + + + + + + diff --git a/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestPassword/index.jelly b/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestPassword/index.jelly new file mode 100644 index 0000000000..151e1d9000 --- /dev/null +++ b/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestPassword/index.jelly @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestStaplerPage/index.jelly b/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestStaplerPage/index.jelly new file mode 100644 index 0000000000..c721c3ea0f --- /dev/null +++ b/test/src/test/resources/jenkins/security/RedactSecretJsonInErrorMessageSanitizerHtmlTest/TestStaplerPage/index.jelly @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/test/src/test/resources/jenkins/security/escape.jelly b/test/src/test/resources/jenkins/security/escape.jelly new file mode 100644 index 0000000000..60db6abc89 --- /dev/null +++ b/test/src/test/resources/jenkins/security/escape.jelly @@ -0,0 +1,8 @@ + + + + + ${h.xmlUnescape("Injection: <script> alert('Oh Oh');</script> end")} + + + diff --git a/test/src/test/resources/jenkins/security/stapler/Security867Test/NotRootAction2/secret.jelly b/test/src/test/resources/jenkins/security/stapler/Security867Test/NotRootAction2/secret.jelly new file mode 100644 index 0000000000..05fb260ccb --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/Security867Test/NotRootAction2/secret.jelly @@ -0,0 +1,5 @@ + + + +

s3cr3t

+
diff --git a/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction1/public.jelly b/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction1/public.jelly new file mode 100644 index 0000000000..aae5f62d2c --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction1/public.jelly @@ -0,0 +1,5 @@ + + + +

Test OK

+
diff --git a/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction3/showConfig.jelly b/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction3/showConfig.jelly new file mode 100644 index 0000000000..32943accd9 --- /dev/null +++ b/test/src/test/resources/jenkins/security/stapler/Security867Test/RootAction3/showConfig.jelly @@ -0,0 +1,5 @@ + + + +

Config: ${it.myConfig}

+
diff --git a/war/src/main/webapp/scripts/hudson-behavior.js b/war/src/main/webapp/scripts/hudson-behavior.js index 5b98df43e7..a05dea7c90 100644 --- a/war/src/main/webapp/scripts/hudson-behavior.js +++ b/war/src/main/webapp/scripts/hudson-behavior.js @@ -2588,7 +2588,12 @@ function buildFormTree(form) { addProperty(p, e.name.substring(r), e.value); } break; - + case "password": + p = findParent(e); + addProperty(p, e.name, e.value); + // must be kept in sync with RedactSecretJsonForTraceSanitizer.REDACT_KEY + addProperty(p, "$redact", shortenName(e.name)); + break; default: p = findParent(e); addProperty(p, e.name, e.value);