Skip to content

Commit 7b73146

Browse files
authored
Validate file paths for FSDirectory and Replicator (#1357)
1 parent b64bc65 commit 7b73146

13 files changed

Lines changed: 420 additions & 4 deletions

File tree

src/Lucene.Net.Replicator/LocalReplicator.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using J2N.Threading.Atomic;
2+
using Lucene.Net.Support.Text;
23
using Lucene.Net.Support.Threading;
34
using System;
45
using System.Collections.Generic;
@@ -256,6 +257,12 @@ public virtual long ExpirationThreshold
256257

257258
public virtual Stream ObtainFile(string sessionId, string source, string fileName)
258259
{
260+
// LUCENENET-specific: validate the file name is valid for replication
261+
if (!fileName.IsValidSinglePathComponent())
262+
{
263+
throw new ArgumentException("File name is not valid for replication", nameof(fileName));
264+
}
265+
259266
UninterruptableMonitor.Enter(syncLock);
260267
try
261268
{

src/Lucene.Net.Replicator/PerSessionDirectoryFactory.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Lucene.Net.Store;
2+
using Lucene.Net.Support.Text;
23
using System;
34
using System.IO;
45
using Directory = Lucene.Net.Store.Directory;
@@ -42,6 +43,17 @@ public PerSessionDirectoryFactory(string workingDirectory)
4243

4344
public virtual Directory GetDirectory(string sessionId, string source)
4445
{
46+
// LUCENENET-specific: validate sessionId and source are valid for paths
47+
if (!sessionId.IsValidSinglePathComponent())
48+
{
49+
throw new ArgumentException("Session ID is not valid for replication", nameof(sessionId));
50+
}
51+
52+
if (!source.IsValidSinglePathComponent())
53+
{
54+
throw new ArgumentException("Source is not valid for replication", nameof(source));
55+
}
56+
4557
string sourceDirectory = Path.Combine(workingDirectory, sessionId, source);
4658
System.IO.Directory.CreateDirectory(sourceDirectory);
4759
if (!System.IO.Directory.Exists(sourceDirectory))
@@ -51,7 +63,16 @@ public virtual Directory GetDirectory(string sessionId, string source)
5163

5264
public virtual void CleanupSession(string sessionId)
5365
{
54-
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("sessionID cannot be empty", nameof(sessionId));
66+
if (string.IsNullOrEmpty(sessionId))
67+
{
68+
throw new ArgumentException("sessionID cannot be empty", nameof(sessionId));
69+
}
70+
71+
// LUCENENET-specific: validate sessionId is valid for paths
72+
if (!sessionId.IsValidSinglePathComponent())
73+
{
74+
throw new ArgumentException("Session ID is not valid for replication", nameof(sessionId));
75+
}
5576

5677
string sessionDirectory = Path.Combine(workingDirectory, sessionId);
5778
System.IO.Directory.Delete(sessionDirectory, true);

src/Lucene.Net.Replicator/SessionToken.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Threading;
88
using System.Threading.Tasks;
99
using Lucene.Net.Support.IO;
10+
using Lucene.Net.Support.Text;
1011

1112
namespace Lucene.Net.Replicator
1213
{
@@ -76,7 +77,16 @@ public SessionToken(IDataInput reader)
7677
IList<RevisionFile> files = new JCG.List<RevisionFile>(numFiles);
7778
for (int i = 0; i < numFiles; i++)
7879
{
79-
files.Add(new RevisionFile(reader.ReadUTF(), reader.ReadInt64()));
80+
string fileName = reader.ReadUTF();
81+
long length = reader.ReadInt64();
82+
83+
// LUCENENET-specific: validate that fileName is valid for replication
84+
if (!fileName.IsValidSinglePathComponent())
85+
{
86+
throw new ArgumentException("File name is not valid for replication", nameof(fileName));
87+
}
88+
89+
files.Add(new RevisionFile(fileName, length));
8090
}
8191
sourceFiles.Add(source, files);
8292
--numSources;

src/Lucene.Net.Tests.Replicator/LocalReplicatorTest.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Lucene.Net.Attributes;
12
using Lucene.Net.Documents;
23
using Lucene.Net.Index;
34
using Lucene.Net.Support.Threading;
@@ -91,6 +92,27 @@ public void TestObtainFileAlreadyClosed()
9192
}
9293
}
9394

95+
// LUCENENET-specific: covers fileName validation in LocalReplicator.ObtainFile.
96+
// Validation runs before session lookup, so a fresh (never-published) replicator is sufficient.
97+
[Test, LuceneNetSpecific]
98+
[TestCase("../../a.txt")]
99+
[TestCase("..\\a.txt")]
100+
[TestCase("/a/b")]
101+
[TestCase("C:\\folder\\file")]
102+
[TestCase("subdir/file.txt")]
103+
[TestCase("subdir\\file.txt")]
104+
[TestCase("..")]
105+
[TestCase(".")]
106+
[TestCase("name\0extra")]
107+
[TestCase("")]
108+
public void TestObtainFileRejectsInvalidFileName(string invalidFileName)
109+
{
110+
Assert.Throws<ArgumentException>(() =>
111+
{
112+
using Stream _ = replicator.ObtainFile("session1", "src", invalidFileName);
113+
}, $"ObtainFile should reject fileName '{invalidFileName}'");
114+
}
115+
94116
[Test]
95117
public void TestPublishAlreadyClosed()
96118
{
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using Lucene.Net.Attributes;
2+
using Lucene.Net.Util;
3+
using NUnit.Framework;
4+
using System;
5+
using System.IO;
6+
using Directory = Lucene.Net.Store.Directory;
7+
8+
namespace Lucene.Net.Replicator
9+
{
10+
/*
11+
* Licensed to the Apache Software Foundation (ASF) under one or more
12+
* contributor license agreements. See the NOTICE file distributed with
13+
* this work for additional information regarding copyright ownership.
14+
* The ASF licenses this file to You under the Apache License, Version 2.0
15+
* (the "License"); you may not use this file except in compliance with
16+
* the License. You may obtain a copy of the License at
17+
*
18+
* http://www.apache.org/licenses/LICENSE-2.0
19+
*
20+
* Unless required by applicable law or agreed to in writing, software
21+
* distributed under the License is distributed on an "AS IS" BASIS,
22+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
23+
* See the License for the specific language governing permissions and
24+
* limitations under the License.
25+
*/
26+
27+
// LUCENENET-specific: covers sessionId and source name validation in PerSessionDirectoryFactory
28+
// for the GetDirectory and CleanupSession entry points.
29+
[LuceneNetSpecific]
30+
public class PerSessionDirectoryFactoryTest : ReplicatorTestCase
31+
{
32+
private static readonly string[] InvalidPathComponents =
33+
{
34+
"../a",
35+
"..\\a",
36+
"/a",
37+
"C:\\folder",
38+
"subdir/segment",
39+
"subdir\\segment",
40+
"..",
41+
".",
42+
"name\0extra",
43+
"",
44+
};
45+
46+
[Test]
47+
[TestCaseSource(nameof(InvalidPathComponents))]
48+
public void TestGetDirectoryRejectsInvalidSessionId(string sessionId)
49+
{
50+
DirectoryInfo workDir = CreateTempDir("perSessionFactoryGetSession");
51+
PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
52+
53+
Assert.Throws<ArgumentException>(() =>
54+
{
55+
using Directory _ = factory.GetDirectory(sessionId, "src");
56+
}, $"GetDirectory should reject sessionId '{sessionId}'");
57+
}
58+
59+
[Test]
60+
[TestCaseSource(nameof(InvalidPathComponents))]
61+
public void TestGetDirectoryRejectsInvalidSource(string source)
62+
{
63+
DirectoryInfo workDir = CreateTempDir("perSessionFactoryGetSource");
64+
PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
65+
66+
Assert.Throws<ArgumentException>(() =>
67+
{
68+
using Directory _ = factory.GetDirectory("session1", source);
69+
}, $"GetDirectory should reject source '{source}'");
70+
}
71+
72+
[Test]
73+
[TestCaseSource(nameof(InvalidPathComponents))]
74+
public void TestCleanupSessionRejectsInvalidSessionId(string sessionId)
75+
{
76+
DirectoryInfo workDir = CreateTempDir("perSessionFactoryCleanup");
77+
PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
78+
79+
Assert.Throws<ArgumentException>(() =>
80+
{
81+
factory.CleanupSession(sessionId);
82+
}, $"CleanupSession should reject sessionId '{sessionId}'");
83+
}
84+
85+
[Test]
86+
public void TestCleanupSessionDoesNotTouchSiblingDirectory()
87+
{
88+
// A sessionId that resolves to a sibling of the workingDirectory must be rejected by
89+
// validation. The sibling directory and its contents must remain untouched.
90+
DirectoryInfo workDir = CreateTempDir("perSessionFactorySiblingWork");
91+
DirectoryInfo sibling = CreateTempDir("perSessionFactorySibling");
92+
string siblingFile = Path.Combine(sibling.FullName, "marker.txt");
93+
File.WriteAllText(siblingFile, "untouched");
94+
95+
PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
96+
// Path.GetRelativePath is not available on .NET Framework; compute manually.
97+
// Both temp dirs share the same parent, so the relative path is "../<siblingName>".
98+
string relative = Path.Combine("..", sibling.Name);
99+
100+
Assert.Throws<ArgumentException>(() => factory.CleanupSession(relative));
101+
assertTrue("sibling directory must still exist", System.IO.Directory.Exists(sibling.FullName));
102+
assertTrue("sibling file must still exist", File.Exists(siblingFile));
103+
}
104+
105+
[Test]
106+
public void TestGetDirectoryAcceptsValidNames()
107+
{
108+
DirectoryInfo workDir = CreateTempDir("perSessionFactoryValid");
109+
PerSessionDirectoryFactory factory = new PerSessionDirectoryFactory(workDir.FullName);
110+
111+
using Directory dir = factory.GetDirectory("session-abc", "src");
112+
assertNotNull(dir);
113+
114+
string expected = Path.Combine(workDir.FullName, "session-abc", "src");
115+
assertTrue("expected session directory to exist on disk", System.IO.Directory.Exists(expected));
116+
}
117+
}
118+
}

src/Lucene.Net.Tests.Replicator/SessionTokenTest.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,60 @@ public void TestToString()
101101
Assert.IsFalse(result.Contains("System.Collections.Generic.Dictionary"), "Should not contain generic Dictionary type");
102102
}
103103

104+
// LUCENENET-specific: covers RevisionFile.FileName validation in the
105+
// SessionToken(IDataInput) deserialization constructor.
106+
[Test, LuceneNetSpecific]
107+
[TestCase("../../../a.txt")]
108+
[TestCase("..\\..\\a.txt")]
109+
[TestCase("/a/b")]
110+
[TestCase("C:\\folder\\file")]
111+
[TestCase("subdir/file.txt")]
112+
[TestCase("..")]
113+
[TestCase(".")]
114+
[TestCase("name\0extra")]
115+
[TestCase("")]
116+
public void TestDeserializeRejectsInvalidFileName(string invalidFileName)
117+
{
118+
using MemoryStream ms = new MemoryStream();
119+
DataOutputStream dos = new DataOutputStream(ms);
120+
dos.WriteUTF("session1");
121+
dos.WriteUTF("ver1");
122+
dos.WriteInt32(1);
123+
dos.WriteUTF("source1");
124+
dos.WriteInt32(1);
125+
dos.WriteUTF(invalidFileName);
126+
dos.WriteInt64(123L);
127+
dos.Flush();
128+
ms.Position = 0;
129+
130+
Assert.Throws<ArgumentException>(() =>
131+
{
132+
_ = new SessionToken(new DataInputStream(ms));
133+
}, $"SessionToken should reject RevisionFile.FileName '{invalidFileName}'");
134+
}
135+
136+
[Test, LuceneNetSpecific]
137+
[TestCase("segments.gen")]
138+
[TestCase("_0.cfs")]
139+
[TestCase(".hidden")]
140+
public void TestDeserializeAcceptsValidFileName(string fileName)
141+
{
142+
using MemoryStream ms = new MemoryStream();
143+
DataOutputStream dos = new DataOutputStream(ms);
144+
dos.WriteUTF("session1");
145+
dos.WriteUTF("ver1");
146+
dos.WriteInt32(1);
147+
dos.WriteUTF("source1");
148+
dos.WriteInt32(1);
149+
dos.WriteUTF(fileName);
150+
dos.WriteInt64(123L);
151+
dos.Flush();
152+
ms.Position = 0;
153+
154+
SessionToken token = new SessionToken(new DataInputStream(ms));
155+
assertEquals(fileName, token.SourceFiles["source1"][0].FileName);
156+
}
157+
104158
// Mock implementation for testing
105159
private class MockRevision : IRevision
106160
{

0 commit comments

Comments
 (0)