Skip to content

Commit c069b79

Browse files
committed
[SECURITY-1878]
1 parent 7078a70 commit c069b79

File tree

4 files changed

+301
-9
lines changed

4 files changed

+301
-9
lines changed

src/main/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpoint.java

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@
2929
import com.cloudbees.plugins.credentials.common.StandardListBoxModel;
3030
import com.cloudbees.plugins.credentials.domains.DomainRequirement;
3131
import com.cloudbees.plugins.credentials.domains.HostnameRequirement;
32-
32+
import hudson.AbortException;
33+
import hudson.EnvVars;
3334
import hudson.Extension;
3435
import hudson.FilePath;
36+
import hudson.Launcher;
3537
import hudson.Util;
3638
import hudson.model.AbstractBuild;
3739
import hudson.model.AbstractDescribableImpl;
@@ -41,17 +43,18 @@
4143
import hudson.model.Run;
4244
import hudson.model.TaskListener;
4345
import hudson.remoting.VirtualChannel;
46+
import hudson.slaves.WorkspaceList;
47+
import hudson.util.FormValidation;
4448
import hudson.util.ListBoxModel;
4549
import jenkins.authentication.tokens.api.AuthenticationTokens;
4650
import jenkins.model.Jenkins;
47-
51+
import org.jenkinsci.plugins.docker.commons.tools.DockerTool;
4852
import org.kohsuke.stapler.AncestorInPath;
4953
import org.kohsuke.stapler.DataBoundConstructor;
5054

5155
import javax.annotation.CheckForNull;
5256
import javax.annotation.Nonnull;
5357
import javax.annotation.Nullable;
54-
5558
import java.io.IOException;
5659
import java.net.MalformedURLException;
5760
import java.net.URL;
@@ -62,12 +65,10 @@
6265
import java.util.regex.Matcher;
6366
import java.util.regex.Pattern;
6467

65-
import static com.cloudbees.plugins.credentials.CredentialsMatchers.*;
66-
import hudson.AbortException;
67-
import hudson.EnvVars;
68-
import hudson.Launcher;
69-
import hudson.slaves.WorkspaceList;
70-
import org.jenkinsci.plugins.docker.commons.tools.DockerTool;
68+
import static com.cloudbees.plugins.credentials.CredentialsMatchers.allOf;
69+
import static com.cloudbees.plugins.credentials.CredentialsMatchers.firstOrNull;
70+
import static com.cloudbees.plugins.credentials.CredentialsMatchers.withId;
71+
import static org.jenkinsci.plugins.docker.commons.credentials.ImageNameValidator.validateUserAndRepo;
7172

7273
/**
7374
* Encapsulates the endpoint of DockerHub and how to interact with it.
@@ -312,6 +313,12 @@ public String imageName(@Nonnull String userAndRepo) throws IOException {
312313
if (userAndRepo == null) {
313314
throw new IllegalArgumentException("Image name cannot be null.");
314315
}
316+
317+
final FormValidation validation = validateUserAndRepo(userAndRepo);
318+
if (validation.kind != FormValidation.Kind.OK) {
319+
throw validation;
320+
}
321+
315322
if (url == null) {
316323
return userAndRepo;
317324
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*
2+
* The MIT License
3+
*
4+
* Copyright (c) 2021, CloudBees, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
package org.jenkinsci.plugins.docker.commons.credentials;
25+
26+
import edu.umd.cs.findbugs.annotations.NonNull;
27+
import hudson.util.FormValidation;
28+
import org.apache.commons.lang.StringUtils;
29+
30+
import javax.annotation.CheckForNull;
31+
import java.util.Arrays;
32+
import java.util.regex.Pattern;
33+
34+
public class ImageNameValidator {
35+
36+
private static /*almost final*/ boolean SKIP = Boolean.getBoolean(ImageNameValidator.class.getName() + ".SKIP");
37+
38+
/**
39+
* If the validation is set to be skipped.
40+
*
41+
* I.e. the system property <code>org.jenkinsci.plugins.docker.commons.credentials.ImageNameValidator.SKIP</code>
42+
* is set to <code>true</code>.
43+
* When this is se to true {@link #validateName(String)}, {@link #validateTag(String)} and {@link #validateUserAndRepo(String)}
44+
* returns {@link FormValidation#ok()} immediately without performing the validation.
45+
*
46+
* @return true if validation is skipped.
47+
*/
48+
public static boolean skipped() {
49+
return SKIP;
50+
}
51+
52+
/**
53+
* Splits a repository id namespace/name into it's three components (repo/namespace[/*],name,tag)
54+
*
55+
* @param userAndRepo the repository ID namespace/name (ie. "jenkinsci/workflow-demo:latest").
56+
* The namespace can have more than one path element.
57+
* @return an array where position 0 is the namespace, 1 is the name and 2 is the tag.
58+
* Any position could be <code>null</code>
59+
*/
60+
public static @NonNull String[] splitUserAndRepo(@NonNull String userAndRepo) {
61+
String[] args = new String[3];
62+
if (StringUtils.isEmpty(userAndRepo)) {
63+
return args;
64+
}
65+
int slashIdx = userAndRepo.lastIndexOf('/');
66+
int tagIdx = userAndRepo.lastIndexOf(':');
67+
if (tagIdx == -1 && slashIdx == -1) {
68+
args[1] = userAndRepo;
69+
} else if (tagIdx < slashIdx) {
70+
//something:port/something or something/something
71+
args[0] = userAndRepo.substring(0, slashIdx);
72+
args[1] = userAndRepo.substring(slashIdx + 1);
73+
} else {
74+
if (slashIdx != -1) {
75+
args[0] = userAndRepo.substring(0, slashIdx);
76+
args[1] = userAndRepo.substring(slashIdx + 1);
77+
}
78+
if (tagIdx > 0) {
79+
int start = slashIdx > 0 ? slashIdx + 1 : 0;
80+
args[1] = userAndRepo.substring(start, tagIdx);
81+
if (tagIdx < userAndRepo.length() - 1) {
82+
args[2] = userAndRepo.substring(tagIdx + 1);
83+
}
84+
}
85+
}
86+
return args;
87+
}
88+
89+
/**
90+
* Validates the string as <code>[registry/repo/]name[:tag]</code>
91+
* @param userAndRepo the image id
92+
* @return if it is valid or not, or OK if set to {@link #SKIP}.
93+
*
94+
* @see #VALID_NAME_COMPONENT
95+
* @see #VALID_TAG
96+
*/
97+
public static @NonNull FormValidation validateUserAndRepo(@NonNull String userAndRepo) {
98+
if (SKIP) {
99+
return FormValidation.ok();
100+
}
101+
final String[] args = splitUserAndRepo(userAndRepo);
102+
if (StringUtils.isBlank(args[0]) && StringUtils.isBlank(args[1]) && StringUtils.isBlank(args[2])) {
103+
return FormValidation.error("Bad imageName format: %s", userAndRepo);
104+
}
105+
final FormValidation name = validateName(args[1]);
106+
final FormValidation tag = validateTag(args[2]);
107+
if (name.kind == FormValidation.Kind.OK && tag.kind == FormValidation.Kind.OK) {
108+
return FormValidation.ok();
109+
}
110+
if (name.kind == FormValidation.Kind.OK) {
111+
return tag;
112+
}
113+
if (tag.kind == FormValidation.Kind.OK) {
114+
return name;
115+
}
116+
return FormValidation.aggregate(Arrays.asList(name, tag));
117+
}
118+
119+
/**
120+
* Calls {@link #validateUserAndRepo(String)} and if the result is not OK throws it as an exception.
121+
*
122+
* @param userAndRepo the image id
123+
* @throws FormValidation if not OK
124+
*/
125+
public static void checkUserAndRepo(@NonNull String userAndRepo) throws FormValidation {
126+
final FormValidation validation = validateUserAndRepo(userAndRepo);
127+
if (validation.kind != FormValidation.Kind.OK) {
128+
throw validation;
129+
}
130+
}
131+
132+
/**
133+
* A tag name must be valid ASCII and may contain
134+
* lowercase and uppercase letters, digits, underscores, periods and dashes.
135+
* A tag name may not start with a period or a dash and may contain a maximum of 128 characters.
136+
*
137+
* @see <a href="https://docs.docker.com/engine/reference/commandline/tag/">docker tag</a>
138+
*/
139+
public static final Pattern VALID_TAG = Pattern.compile("^[a-zA-Z0-9_]([a-zA-Z0-9_.-]){0,127}");
140+
141+
142+
/**
143+
* Validates a tag is following the rules.
144+
*
145+
* If the tag is null or the empty string it is considered valid.
146+
*
147+
* @param tag the tag to validate.
148+
* @return the validation result
149+
* @see #VALID_TAG
150+
*/
151+
public static @NonNull FormValidation validateTag(@CheckForNull String tag) {
152+
if (SKIP) {
153+
return FormValidation.ok();
154+
}
155+
if (StringUtils.isEmpty(tag)) {
156+
return FormValidation.ok();
157+
}
158+
if (tag.length() > 128) {
159+
return FormValidation.error("Tag length > 128");
160+
}
161+
if (VALID_TAG.matcher(tag).matches()) {
162+
return FormValidation.ok();
163+
} else {
164+
return FormValidation.error("Tag must follow the pattern '%s'", VALID_TAG.pattern());
165+
}
166+
}
167+
168+
/**
169+
* Calls {@link #validateTag(String)} and if not OK throws the exception.
170+
*
171+
* @param tag the tag
172+
* @throws FormValidation if not OK
173+
*/
174+
public static void checkTag(@CheckForNull String tag) throws FormValidation {
175+
final FormValidation validation = validateTag(tag);
176+
if (validation.kind != FormValidation.Kind.OK) {
177+
throw validation;
178+
}
179+
}
180+
181+
/**
182+
* Name components may contain lowercase letters, digits and separators.
183+
* A separator is defined as a period, one or two underscores, or one or more dashes.
184+
* A name component may not start or end with a separator.
185+
*
186+
* @see <a href="https://docs.docker.com/engine/reference/commandline/tag/">docker tag</a>
187+
*/
188+
public static final Pattern VALID_NAME_COMPONENT = Pattern.compile("^[a-zA-Z0-9]+((\\.|_|__|-+)[a-zA-Z0-9]+)*$");
189+
190+
/**
191+
* Validates a docker image name that it is following the rules as a single name component.
192+
*
193+
* If the name is null or the empty string it is not considered valid.
194+
*
195+
* @param name the name
196+
* @return the validation result
197+
* @see #VALID_NAME_COMPONENT
198+
*/
199+
public static @NonNull FormValidation validateName(@CheckForNull String name) {
200+
if (SKIP) {
201+
return FormValidation.ok();
202+
}
203+
if (StringUtils.isEmpty(name)) {
204+
return FormValidation.error("Missing name.");
205+
}
206+
if (VALID_NAME_COMPONENT.matcher(name).matches()) {
207+
return FormValidation.ok();
208+
} else {
209+
return FormValidation.error("Name must follow the pattern '%s'", VALID_NAME_COMPONENT.pattern());
210+
}
211+
}
212+
213+
/**
214+
* Calls {@link #validateName(String)} and if not OK throws the exception.
215+
*
216+
* @param name the name
217+
* @throws FormValidation if not OK
218+
*/
219+
public static void checkName(String name) throws FormValidation {
220+
final FormValidation validation = validateName(name);
221+
if (validation.kind != FormValidation.Kind.OK) {
222+
throw validation;
223+
}
224+
}
225+
}

src/test/java/org/jenkinsci/plugins/docker/commons/credentials/DockerRegistryEndpointTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ public void testParseWithTags() throws Exception {
8989
public void testParseFullyQualifiedImageName() throws Exception {
9090
assertEquals("private-repo:5000/test-image", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("private-repo:5000/test-image"));
9191
assertEquals("private-repo:5000/test-image", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("test-image"));
92+
assertEquals("private-repo:5000/test-image:dev", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("private-repo:5000/test-image:dev"));
93+
assertEquals("private-repo:5000/test-image:dev", new DockerRegistryEndpoint("http://private-repo:5000/", null).imageName("test-image:dev"));
9294
}
9395

9496
@Issue("JENKINS-39181")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.jenkinsci.plugins.docker.commons.credentials;
2+
3+
import hudson.util.FormValidation;
4+
import org.junit.Test;
5+
import org.junit.runner.RunWith;
6+
import org.junit.runners.Parameterized;
7+
8+
import static org.junit.Assert.*;
9+
10+
/**
11+
* Tests various inputs to {@link ImageNameValidator#validateUserAndRepo(String)}.
12+
*/
13+
@RunWith(Parameterized.class)
14+
public class ImageNameValidatorTest {
15+
16+
@Parameterized.Parameters(name = "{index}:{0}") public static Object[][] data(){
17+
return new Object[][] {
18+
{"jenkinsci/workflow-demo", FormValidation.Kind.OK},
19+
{"docker:80/jenkinsci/workflow-demo", FormValidation.Kind.OK},
20+
{"jenkinsci/workflow-demo:latest", FormValidation.Kind.OK},
21+
{"docker:80/jenkinsci/workflow-demo:latest", FormValidation.Kind.OK},
22+
{"workflow-demo:latest", FormValidation.Kind.OK},
23+
{"workflow-demo", FormValidation.Kind.OK},
24+
{":tag", FormValidation.Kind.ERROR},
25+
{"name:tag", FormValidation.Kind.OK},
26+
{"name:.tag", FormValidation.Kind.ERROR},
27+
{"name:-tag", FormValidation.Kind.ERROR},
28+
{"name:.tag.", FormValidation.Kind.ERROR},
29+
{"name:tag.", FormValidation.Kind.OK},
30+
{"name:tag-", FormValidation.Kind.OK},
31+
{"_name:tag", FormValidation.Kind.ERROR},
32+
{"na___me:tag", FormValidation.Kind.ERROR},
33+
{"na__me:tag", FormValidation.Kind.OK},
34+
{"name:tag\necho hello", FormValidation.Kind.ERROR},
35+
{"name\necho hello:tag", FormValidation.Kind.ERROR},
36+
{"name:tag$BUILD_NUMBER", FormValidation.Kind.ERROR},
37+
{"name$BUILD_NUMBER:tag", FormValidation.Kind.ERROR},
38+
{null, FormValidation.Kind.ERROR},
39+
{"", FormValidation.Kind.ERROR},
40+
{":", FormValidation.Kind.ERROR},
41+
{" ", FormValidation.Kind.ERROR},
42+
43+
};
44+
}
45+
46+
private final String userAndRepo;
47+
private final FormValidation.Kind expected;
48+
49+
public ImageNameValidatorTest(final String userAndRepo, final FormValidation.Kind expected) {
50+
this.userAndRepo = userAndRepo;
51+
this.expected = expected;
52+
}
53+
54+
@Test
55+
public void test() {
56+
assertSame(expected, ImageNameValidator.validateUserAndRepo(userAndRepo).kind);
57+
}
58+
}

0 commit comments

Comments
 (0)