Skip to content

Commit 46e35c9

Browse files
committed
Merge branch 'main' of github.com:line/centraldogma into readonly-per-repository
2 parents 89e1eac + 9e7b7f6 commit 46e35c9

25 files changed

Lines changed: 955 additions & 271 deletions

File tree

it/mirror/src/test/java/com/linecorp/centraldogma/it/mirror/git/CentralDogmaMirrorTest.java

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.Map;
2828
import java.util.concurrent.CompletionException;
2929

30+
import org.jspecify.annotations.Nullable;
3031
import org.junit.jupiter.api.AfterEach;
3132
import org.junit.jupiter.api.BeforeAll;
3233
import org.junit.jupiter.api.BeforeEach;
@@ -281,11 +282,66 @@ void localToRemote_tooManyFiles() throws Exception {
281282
.hasMessageContaining("file");
282283
}
283284

285+
@Test
286+
void localToRemote_gitignore() throws Exception {
287+
pushMirrorSettings("/", "/", MirrorDirection.LOCAL_TO_REMOTE, "*.log\n/build/");
288+
289+
// Add files - some match gitignore patterns.
290+
localClient.forRepo(projName, REPO_FOO)
291+
.commit("Add files",
292+
Change.ofTextUpsert("/app.txt", "app content"),
293+
Change.ofTextUpsert("/error.log", "log content"),
294+
Change.ofTextUpsert("/build/output.txt", "build output"),
295+
Change.ofJsonUpsert("/config.json", "{\"key\":\"value\"}"))
296+
.push().join();
297+
298+
mirroringService.mirror().join();
299+
300+
final Map<String, Entry<?>> remoteEntries =
301+
remoteClient.getFiles(projName, REPO_FOO, Revision.HEAD, PathPattern.all()).join();
302+
// Non-ignored files should be mirrored.
303+
assertThat(remoteEntries).containsKey("/app.txt");
304+
assertThat(remoteEntries).containsKey("/config.json");
305+
// Ignored files should NOT be mirrored.
306+
assertThat(remoteEntries).doesNotContainKey("/error.log");
307+
assertThat(remoteEntries).doesNotContainKey("/build/output.txt");
308+
}
309+
310+
@Test
311+
void remoteToLocal_gitignore() throws Exception {
312+
pushMirrorSettings("/", "/", MirrorDirection.REMOTE_TO_LOCAL, "*.log\n/build/");
313+
314+
// Add files to remote - some match gitignore patterns.
315+
remoteClient.forRepo(projName, REPO_FOO)
316+
.commit("Add files",
317+
Change.ofTextUpsert("/app.txt", "app content"),
318+
Change.ofTextUpsert("/error.log", "log content"),
319+
Change.ofTextUpsert("/build/output.txt", "build output"),
320+
Change.ofJsonUpsert("/config.json", "{\"key\":\"value\"}"))
321+
.push().join();
322+
323+
mirroringService.mirror().join();
324+
325+
final Map<String, Entry<?>> localEntries =
326+
localClient.getFiles(projName, REPO_FOO, Revision.HEAD, PathPattern.all()).join();
327+
// Non-ignored files should be mirrored.
328+
assertThat(localEntries).containsKey("/app.txt");
329+
assertThat(localEntries).containsKey("/config.json");
330+
// Ignored files should NOT be mirrored.
331+
assertThat(localEntries).doesNotContainKey("/error.log");
332+
assertThat(localEntries).doesNotContainKey("/build/output.txt");
333+
}
334+
284335
private void pushMirrorSettings(String localPath, String remotePath) {
285-
pushMirrorSettings(localPath, remotePath, MirrorDirection.LOCAL_TO_REMOTE);
336+
pushMirrorSettings(localPath, remotePath, MirrorDirection.LOCAL_TO_REMOTE, null);
286337
}
287338

288339
private void pushMirrorSettings(String localPath, String remotePath, MirrorDirection direction) {
340+
pushMirrorSettings(localPath, remotePath, direction, null);
341+
}
342+
343+
private void pushMirrorSettings(String localPath, String remotePath,
344+
MirrorDirection direction, @Nullable String gitignore) {
289345
final InetSocketAddress remoteAddr = remoteDogma.serverAddress();
290346
final String remoteUri = "dogma://" + remoteAddr.getHostString() + ':' + remoteAddr.getPort() +
291347
'/' + projName + '/' + REPO_FOO + ".dogma" + remotePath;
@@ -306,20 +362,26 @@ private void pushMirrorSettings(String localPath, String remotePath, MirrorDirec
306362
}
307363
}
308364

365+
final StringBuilder config = new StringBuilder();
366+
config.append('{')
367+
.append(" \"id\": \"foo\",")
368+
.append(" \"enabled\": true,")
369+
.append(" \"direction\": \"").append(direction).append("\",")
370+
.append(" \"localRepo\": \"").append(REPO_FOO).append("\",")
371+
.append(" \"localPath\": \"").append(localPath).append("\",")
372+
.append(" \"remoteUri\": \"").append(remoteUri).append("\",")
373+
.append(" \"schedule\": \"0 0 0 1 1 ? 2099\",")
374+
.append(" \"credentialName\": \"").append(credName).append('"');
375+
if (gitignore != null) {
376+
config.append(", \"gitignore\": \"").append(gitignore.replace("\n", "\\n")).append('"');
377+
}
378+
config.append('}');
379+
309380
localClient.forRepo(projName, Project.REPO_DOGMA)
310381
.commit("Add mirror config",
311382
Change.ofJsonUpsert(
312383
"/repos/" + REPO_FOO + "/mirrors/foo.json",
313-
'{' +
314-
" \"id\": \"foo\"," +
315-
" \"enabled\": true," +
316-
" \"direction\": \"" + direction + "\"," +
317-
" \"localRepo\": \"" + REPO_FOO + "\"," +
318-
" \"localPath\": \"" + localPath + "\"," +
319-
" \"remoteUri\": \"" + remoteUri + "\"," +
320-
" \"schedule\": \"0 0 0 1 1 ? 2099\"," +
321-
" \"credentialName\": \"" + credName + '"' +
322-
'}'))
384+
config.toString()))
323385
.push().join();
324386
}
325387
}

server-auth/saml/src/main/java/com/linecorp/centraldogma/server/auth/saml/SamlAuthSsoHandler.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import org.opensaml.saml.saml2.core.Response;
4141
import org.owasp.encoder.Encode;
4242

43+
import com.google.common.annotations.VisibleForTesting;
4344
import com.google.common.base.Strings;
4445

4546
import com.linecorp.armeria.common.AggregatedHttpRequest;
@@ -145,10 +146,10 @@ public HttpResponse loginSucceeded(ServiceRequestContext ctx, AggregatedHttpRequ
145146
final String redirectionScript;
146147
if (!Strings.isNullOrEmpty(relayState)) {
147148
final String trimmed = relayState.trim();
148-
if (!trimmed.startsWith("/") || trimmed.startsWith("//")) {
149-
redirectionScript = "window.location.href='/'";
150-
} else {
149+
if (isSafeRelayState(trimmed)) {
151150
redirectionScript = "window.location.href='" + Encode.forJavaScript(trimmed) + '\'';
151+
} else {
152+
redirectionScript = "window.location.href='/'";
152153
}
153154
} else {
154155
redirectionScript = "window.location.href='/'";
@@ -173,6 +174,30 @@ public HttpResponse loginSucceeded(ServiceRequestContext ctx, AggregatedHttpRequ
173174
}));
174175
}
175176

177+
/**
178+
* Returns whether the specified {@code relayState} is safe to be used as a redirect target. Only a
179+
* relative path that starts with exactly one {@code '/'} is allowed in order to prevent an open
180+
* redirect. Note that validating the raw string is not enough: a browser normalizes a backslash to
181+
* {@code '/'} and strips control characters such as TAB, CR and LF from a URL before navigating, so a
182+
* value like {@code "/\evil.example"} or {@code "/\t/evil.example"} would otherwise be resolved to a
183+
* protocol-relative URL pointing at an attacker host. Therefore backslashes and control characters are
184+
* rejected as well.
185+
*/
186+
@VisibleForTesting
187+
static boolean isSafeRelayState(String relayState) {
188+
if (!relayState.startsWith("/") || relayState.startsWith("//")) {
189+
// Not a relative path, or a protocol-relative URL such as '//evil.example/'.
190+
return false;
191+
}
192+
for (int i = 0; i < relayState.length(); i++) {
193+
final char c = relayState.charAt(i);
194+
if (c == '\\' || Character.isISOControl(c)) {
195+
return false;
196+
}
197+
}
198+
return true;
199+
}
200+
176201
private HttpResponse httpResponse(LoginResult loginResult, String redirectionScript) {
177202
final Cookie cookie = createSessionCookie(sessionCookieName, loginResult.sessionCookieValue(),
178203
tlsEnabled, cookieMaxAgeSecond);

server-auth/saml/src/test/java/com/linecorp/centraldogma/server/auth/saml/SamlAuthSsoHandlerTest.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,39 @@ void relayStateIsHtmlEscaped(boolean tlsEnabled) {
9292
assertCookie(tlsEnabled, aggregated.headers(), "3");
9393
}
9494

95+
@ValueSource(strings = {
96+
// Absolute URLs and dangerous schemes.
97+
"https://evil.example/phish",
98+
"javascript:alert(document.domain)",
99+
// Protocol-relative URL.
100+
"//evil.example/",
101+
// Backslash is normalized to '/' by browsers, so this resolves to '//evil.example'.
102+
"/\\evil.example",
103+
"/\\/evil.example",
104+
// Control characters (TAB, CR, LF) are stripped by browsers, so this resolves to
105+
// '//evil.example'.
106+
"/\t/evil.example",
107+
"/\r/evil.example",
108+
"/\n/evil.example",
109+
// Not a path at all.
110+
"evil.example",
111+
})
112+
@ParameterizedTest
113+
void unsafeRelayStateIsRejected(String relayState) {
114+
assertThat(SamlAuthSsoHandler.isSafeRelayState(relayState)).isFalse();
115+
}
116+
117+
@ValueSource(strings = {
118+
"/",
119+
"/dashboard",
120+
"/dashboard?next=/home",
121+
"/a/b/c",
122+
})
123+
@ParameterizedTest
124+
void relativePathRelayStateIsAccepted(String relayState) {
125+
assertThat(SamlAuthSsoHandler.isSafeRelayState(relayState)).isTrue();
126+
}
127+
95128
private static void assertCookie(boolean tlsEnabled, ResponseHeaders responseHeaders, String value) {
96129
final String setCookieValue = responseHeaders.get(HttpHeaderNames.SET_COOKIE);
97130
final Cookie setCookie = Cookie.fromSetCookieHeader(setCookieValue);

server-auth/shiro/src/main/java/com/linecorp/centraldogma/server/auth/shiro/realm/SearchFirstActiveDirectoryRealm.java

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static java.util.Objects.requireNonNull;
2121

2222
import java.time.Duration;
23+
import java.util.regex.Matcher;
2324
import java.util.regex.Pattern;
2425

2526
import javax.naming.AuthenticationException;
@@ -157,10 +158,13 @@ protected String findUserDn(LdapContextFactory ldapContextFactory, String userna
157158
ctrl.setSearchScope(SearchControls.SUBTREE_SCOPE);
158159
ctrl.setTimeLimit(searchTimeoutMillis);
159160

161+
// Escape RFC 4515 filter metacharacters to prevent LDAP filter injection.
162+
final String escapedUsername = escapeLdapFilterValue(username);
160163
final String filter =
161164
searchFilter != null ? USERNAME_PLACEHOLDER.matcher(searchFilter)
162-
.replaceAll(username)
163-
: username;
165+
.replaceAll(Matcher.quoteReplacement(
166+
escapedUsername))
167+
: escapedUsername;
164168
final NamingEnumeration<SearchResult> result = ctx.search(searchBase, filter, ctrl);
165169
try {
166170
if (!result.hasMore()) {
@@ -175,6 +179,38 @@ protected String findUserDn(LdapContextFactory ldapContextFactory, String userna
175179
}
176180
}
177181

182+
/**
183+
* Escapes the characters that have a special meaning in an LDAP search filter, as defined in
184+
* <a href="https://datatracker.ietf.org/doc/html/rfc4515#section-3">RFC 4515 section 3</a>.
185+
*/
186+
static String escapeLdapFilterValue(String value) {
187+
requireNonNull(value, "value");
188+
final StringBuilder sb = new StringBuilder(value.length());
189+
for (int i = 0; i < value.length(); i++) {
190+
final char c = value.charAt(i);
191+
switch (c) {
192+
case '\\':
193+
sb.append("\\5c");
194+
break;
195+
case '*':
196+
sb.append("\\2a");
197+
break;
198+
case '(':
199+
sb.append("\\28");
200+
break;
201+
case ')':
202+
sb.append("\\29");
203+
break;
204+
case '\0':
205+
sb.append("\\00");
206+
break;
207+
default:
208+
sb.append(c);
209+
}
210+
}
211+
return sb.toString();
212+
}
213+
178214
private static UsernamePasswordToken ensureUsernamePasswordToken(AuthenticationToken token) {
179215
if (token instanceof UsernamePasswordToken) {
180216
return (UsernamePasswordToken) token;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2026 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.centraldogma.server.auth.shiro.realm;
18+
19+
import static com.linecorp.centraldogma.server.auth.shiro.realm.SearchFirstActiveDirectoryRealm.escapeLdapFilterValue;
20+
import static org.assertj.core.api.Assertions.assertThat;
21+
22+
import org.junit.jupiter.api.Test;
23+
24+
class SearchFirstActiveDirectoryRealmTest {
25+
26+
@Test
27+
void escapeLeavesOrdinaryValuesUntouched() {
28+
assertThat(escapeLdapFilterValue("alice")).isEqualTo("alice");
29+
assertThat(escapeLdapFilterValue("")).isEmpty();
30+
}
31+
32+
@Test
33+
void escapeNeutralizesFilterMetacharacters() {
34+
assertThat(escapeLdapFilterValue("*")).isEqualTo("\\2a");
35+
assertThat(escapeLdapFilterValue("alice)(uid=*"))
36+
.isEqualTo("alice\\29\\28uid=\\2a");
37+
assertThat(escapeLdapFilterValue("a\\b")).isEqualTo("a\\5cb");
38+
assertThat(escapeLdapFilterValue("a\0b")).isEqualTo("a\\00b");
39+
}
40+
}

server-mirror-dogma/src/main/java/com/linecorp/centraldogma/server/internal/mirror/CentralDogmaMirror.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ protected MirrorResult mirrorLocalToRemote(File workDir, int maxNumFiles, long m
140140
return newMirrorResult(MirrorStatus.UP_TO_DATE, description, triggeredTime);
141141
}
142142

143-
// Read local files under localPath.
144-
final Map<String, Entry<?>> localEntries =
145-
localRepo().find(localHead, localPath() + "**", FIND_ALL_WITH_CONTENT).join();
143+
// Read local files under localPath and apply gitignore filter.
144+
final Map<String, Entry<?>> localEntries = filterByGitignore(
145+
localRepo().find(localHead, localPath() + "**", FIND_ALL_WITH_CONTENT).join(),
146+
localPath());
146147

147148
// Fetch remote files for comparison/removal detection.
148149
final Map<String, Entry<?>> remoteEntries =
@@ -279,9 +280,10 @@ protected MirrorResult mirrorRemoteToLocal(File workDir, CommandExecutor executo
279280
return newMirrorResult(MirrorStatus.UP_TO_DATE, description, triggeredTime);
280281
}
281282

282-
// Fetch all remote files under remotePath.
283-
final Map<String, Entry<?>> remoteEntries =
284-
repo.file(PathPattern.of(remotePath() + "**")).viewRaw(true).get(remoteHead).join();
283+
// Fetch all remote files under remotePath and apply gitignore filter.
284+
final Map<String, Entry<?>> remoteEntries = filterByGitignore(
285+
repo.file(PathPattern.of(remotePath() + "**")).viewRaw(true).get(remoteHead).join(),
286+
remotePath());
285287

286288
// Build Change objects.
287289
final Map<String, Change<?>> changes = new HashMap<>();

0 commit comments

Comments
 (0)