Skip to content

Commit 228b0ad

Browse files
committed
LDEV-694 x-vdir support, create mappings for webserver virtual directories
https://luceeserver.atlassian.net/browse/LDEV-694
1 parent 7898e4a commit 228b0ad

File tree

9 files changed

+302
-0
lines changed

9 files changed

+302
-0
lines changed

ant/build-core.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,7 @@
762762
<jvmarg value="-Dlucee.ssl.checkserveridentity=false"/>
763763
<jvmarg value="-DLUCEE_BUILD_ENV=${LUCEE_BUILD_ENV}"/>
764764
<jvmarg value="-Dlucee.enable.bundle.download=true"/>
765+
<jvmarg value="-Dlucee.vdirs.sharedkey=testsecret"/>
765766

766767
<jvmarg value="-XX:+UnlockExperimentalVMOptions" if:true="${UseEpsilonGC}"/>
767768
<jvmarg value="-XX:+UseEpsilonGC" if:true="${UseEpsilonGC}"/>

core/src/main/java/lucee/runtime/config/ConfigUtil.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,30 @@ private static Object[] getSources(PageContext pc, ConfigPro config, Mapping[] m
12161216
}
12171217
}
12181218

1219+
// virtual directory mappings from x-vdirs header (mod_cfml)
1220+
if (pc != null && config instanceof ConfigWebImpl) {
1221+
Mapping[] vdirMappings = ((ConfigWebImpl) config).getVirtualDirectoryMappings( pc );
1222+
if (vdirMappings != null) {
1223+
for (int i = 0; i < vdirMappings.length; i++) {
1224+
mapping = vdirMappings[i];
1225+
if ((!onlyTopLevel || mapping.isTopLevel()) && lcRealPath.startsWith(mapping.getVirtualLowerCaseWithSlash(), 0)) {
1226+
if (asPageSource) {
1227+
ps = mapping.getPageSource(realPath.substring(mapping.getVirtual().length()));
1228+
if (onlyFirstMatch) return new PageSource[] { ps };
1229+
else list.add(ps);
1230+
}
1231+
else {
1232+
if (mapping instanceof MappingImpl) res = ((MappingImpl) mapping).getResource(realPath.substring(mapping.getVirtual().length()));
1233+
else res = mapping.getPageSource(realPath.substring(mapping.getVirtual().length())).getResource();
1234+
1235+
if (onlyFirstMatch) return new Resource[] { res };
1236+
else list.add(res);
1237+
}
1238+
}
1239+
}
1240+
}
1241+
}
1242+
12191243
if (useDefaultMapping) {
12201244
if (rootApp != null) mapping = rootApp;
12211245
else mapping = thisMappings[thisMappings.length - 1];
@@ -1325,6 +1349,23 @@ public static PageSource getPageSourceExisting(PageContext pc, ConfigPro config,
13251349
}
13261350
}
13271351

1352+
// virtual directory mappings from x-vdirs header (mod_cfml)
1353+
if (pc != null && config instanceof ConfigWebImpl) {
1354+
Mapping[] vdirMappings = ((ConfigWebImpl) config).getVirtualDirectoryMappings( pc );
1355+
if (vdirMappings != null) {
1356+
for (int i = 0; i < vdirMappings.length; i++) {
1357+
mapping = vdirMappings[i];
1358+
if ((!onlyTopLevel || mapping.isTopLevel()) && lcRealPath.startsWith(mapping.getVirtualLowerCaseWithSlash(), 0)) {
1359+
ps = mapping.getPageSource(realPath.substring(mapping.getVirtual().length()));
1360+
if (onlyPhysicalExisting) {
1361+
if (ps.physcalExists()) return ps;
1362+
}
1363+
else if (ps.exists()) return ps;
1364+
}
1365+
}
1366+
}
1367+
}
1368+
13281369
if (useDefaultMapping) {
13291370
if (rootApp != null) mapping = rootApp;
13301371
else mapping = thisMappings[thisMappings.length - 1];

core/src/main/java/lucee/runtime/config/ConfigWebImpl.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ public final class ConfigWebImpl extends ConfigBase implements ConfigWebPro {
129129
private ComponentPathCache componentPathCache = new ComponentPathCache();
130130
private Map<String, Log> logs = new ConcurrentHashMap<>();
131131
private lucee.runtime.rest.Mapping[] restMappings;
132+
private VirtualDirectoryManager virtualDirectoryManager;
132133

133134
public ConfigWebImpl(CFMLFactoryImpl factory, ConfigServerImpl cs, ServletConfig config) {
134135
setInstance(factory, cs, config, false);
@@ -140,6 +141,7 @@ public ConfigWebPro setInstance(CFMLFactoryImpl factory, ConfigServerImpl cs, Se
140141
this.cs = cs;
141142
this.config = config;
142143
helper = new ConfigWebHelper(cs, this);
144+
this.virtualDirectoryManager = new VirtualDirectoryManager(this);
143145

144146
if (reload) reload();
145147
return this;
@@ -1786,6 +1788,17 @@ public Mapping[] getMappings() {
17861788
return mappings;
17871789
}
17881790

1791+
/**
1792+
* Gets virtual directory mappings for the current request from x-vdirs header.
1793+
* Returns null if feature is disabled or no virtual directories in request.
1794+
*
1795+
* @param pc PageContext for current request
1796+
* @return Array of virtual directory Mapping objects, or null
1797+
*/
1798+
public Mapping[] getVirtualDirectoryMappings( PageContext pc ) {
1799+
return virtualDirectoryManager != null ? virtualDirectoryManager.getVirtualDirectoryMappings( pc ) : null;
1800+
}
1801+
17891802
@Override
17901803
public ConfigWebImpl resetMappings() {
17911804
if (mappings != null) {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package lucee.runtime.config;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
8+
import lucee.commons.io.SystemUtil;
9+
import lucee.commons.io.log.Log;
10+
import lucee.commons.io.res.Resource;
11+
import lucee.commons.io.res.util.ResourceUtil;
12+
import lucee.commons.lang.StringUtil;
13+
import lucee.runtime.Mapping;
14+
import lucee.runtime.MappingImpl;
15+
import lucee.runtime.PageContext;
16+
17+
/**
18+
* Manages virtual directory mappings from x-vdirs header (mod_cfml).
19+
*
20+
* Virtual directories are parsed from the x-vdirs header format:
21+
* /virtual1,/physical/path1;/virtual2,/physical/path2;
22+
*
23+
* Security: Requires x-vdirs-sharedkey header to match lucee.vdirs.sharedkey configuration.
24+
* Feature is disabled if lucee.vdirs.sharedkey is not configured.
25+
*/
26+
public class VirtualDirectoryManager {
27+
28+
private static final String CONFIGURED_SHARED_KEY = SystemUtil.getSystemPropOrEnvVar( "lucee.vdirs.sharedkey",
29+
"" );
30+
31+
private final ConfigWeb config;
32+
33+
// Cache: x-vdirs header value hash -> array of Mapping objects
34+
private final Map<Integer, Mapping[]> cache = new ConcurrentHashMap<>( 4 );
35+
36+
private volatile boolean invalidKeyWarned = false;
37+
38+
public VirtualDirectoryManager( ConfigWeb config ) {
39+
this.config = config;
40+
}
41+
42+
/**
43+
* Gets virtual directory mappings for the current request.
44+
* Returns null if feature is disabled or validation fails.
45+
*
46+
* @param pc PageContext for current request
47+
* @return Array of Mapping objects, or null
48+
*/
49+
public Mapping[] getVirtualDirectoryMappings( PageContext pc ) {
50+
// Feature disabled if no shared key configured
51+
if ( StringUtil.isEmpty( CONFIGURED_SHARED_KEY ) ) {
52+
return null;
53+
}
54+
55+
// Read x-vdirs header directly from servlet request (avoids lazy-loading CGI scope)
56+
String xVdirs = pc.getHttpServletRequest().getHeader( "x-vdirs" );
57+
58+
// No virtual directories in this request
59+
if ( StringUtil.isEmpty( xVdirs ) ) {
60+
return null;
61+
}
62+
63+
// Validate shared key
64+
String xVdirsSharedKey = pc.getHttpServletRequest().getHeader( "x-vdirs-sharedkey" );
65+
if ( StringUtil.isEmpty( xVdirsSharedKey ) || !CONFIGURED_SHARED_KEY.equals( xVdirsSharedKey ) ) {
66+
if ( !invalidKeyWarned ) {
67+
invalidKeyWarned = true;
68+
Log log = config.getLog( "application" );
69+
if ( log != null ) {
70+
log.error( "VirtualDirectoryManager",
71+
"x-vdirs header received but x-vdirs-sharedkey is missing or doesn't match configured shared key. Ignoring virtual directories." );
72+
}
73+
}
74+
return null;
75+
}
76+
77+
// Check cache
78+
int cacheKey = xVdirs.hashCode();
79+
Mapping[] cached = cache.get( cacheKey );
80+
if ( cached != null ) {
81+
return cached;
82+
}
83+
84+
// Parse and cache
85+
Mapping[] mappings = parseVirtualDirectories( xVdirs );
86+
if ( mappings != null && mappings.length > 0 ) {
87+
cache.put( cacheKey, mappings );
88+
}
89+
90+
return mappings;
91+
}
92+
93+
/**
94+
* Parses x-vdirs header format: /virtual1,/physical1;/virtual2,/physical2;
95+
*
96+
* @param xVdirs Header value
97+
* @return Array of Mapping objects
98+
*/
99+
private Mapping[] parseVirtualDirectories( String xVdirs ) {
100+
if ( StringUtil.isEmpty( xVdirs ) ) {
101+
return null;
102+
}
103+
104+
// Split by semicolon
105+
String[] entries = xVdirs.split( ";" );
106+
if ( entries.length == 0 ) {
107+
return null;
108+
}
109+
110+
// Parse each entry
111+
List<Mapping> mappings = new ArrayList<>();
112+
for ( String entry : entries ) {
113+
entry = entry.trim();
114+
if ( entry.isEmpty() ) {
115+
continue;
116+
}
117+
118+
// Split by comma
119+
String[] parts = entry.split( ",", 2 );
120+
if ( parts.length != 2 ) {
121+
continue;
122+
}
123+
124+
String virtual = parts[0].trim();
125+
String physical = parts[1].trim();
126+
127+
// Skip invalid entries
128+
if ( virtual.length() <= 1 || physical.length() <= 1 ) {
129+
continue;
130+
}
131+
132+
// Convert Windows backslashes to forward slashes
133+
physical = physical.replace( '\\', '/' );
134+
135+
// Create mapping
136+
try {
137+
Resource physicalResource = ResourceUtil.toResourceExisting( config, physical );
138+
if ( physicalResource != null && physicalResource.exists() ) {
139+
Mapping mapping = new MappingImpl( config, virtual, physical, null, Config.INSPECT_UNDEFINED, ConfigPro.INSPECT_INTERVAL_UNDEFINED,
140+
ConfigPro.INSPECT_INTERVAL_UNDEFINED, true, false, true, true, false, false, null, -1, -1 );
141+
mappings.add( mapping );
142+
}
143+
}
144+
catch ( Exception e ) {
145+
// Log and skip invalid mappings
146+
Log log = config.getLog( "application" );
147+
if ( log != null ) {
148+
log.error( "VirtualDirectoryManager", "Failed to create virtual directory mapping: " + virtual + " -> " + physical, e );
149+
}
150+
}
151+
}
152+
153+
return mappings.isEmpty() ? null : mappings.toArray( new Mapping[0] );
154+
}
155+
156+
/**
157+
* Clears the cache. Called when configuration changes or for testing.
158+
*/
159+
public void clearCache() {
160+
cache.clear();
161+
}
162+
163+
/**
164+
* Returns true if virtual directory feature is enabled (shared key is configured).
165+
*/
166+
public boolean isEnabled() {
167+
return !StringUtil.isEmpty( CONFIGURED_SHARED_KEY );
168+
}
169+
}

core/src/main/java/resource/setting/sysprop-envvar.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,14 @@
971971
"type": "string",
972972
"default": null
973973
},
974+
{
975+
"sysprop": "lucee.vdirs.sharedkey",
976+
"envvar": "LUCEE_VDIRS_SHAREDKEY",
977+
"desc": "Shared secret key for validating x-vdirs header from mod_cfml. When set, enables automatic virtual directory mapping from Apache/IIS aliases. The x-vdirs-sharedkey header must match this value for virtual directories to be processed",
978+
"category": "security",
979+
"type": "string",
980+
"default": null
981+
},
974982
{
975983
"sysprop": "lucee.version",
976984
"envvar": "LUCEE_VERSION",

test/tickets/LDEV0694.cfc

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
component extends="org.lucee.cfml.test.LuceeTestCase" labels="vdirs" {
2+
3+
function run( testResults, testBox ) {
4+
describe( "LDEV-694: Virtual directory mappings from x-vdirs header", function() {
5+
6+
it( "should resolve virtual directory path via expandPath", function() {
7+
var testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV0694/";
8+
var physicalPath = testDir & "virtual";
9+
10+
// Use internalRequest to simulate mod_cfml sending x-vdirs header
11+
var result = internalRequest(
12+
template = createURI( "LDEV0694/test.cfm" ),
13+
headers = {
14+
"x-vdirs" = "/vdir,#physicalPath#",
15+
"x-vdirs-sharedkey" = "testsecret"
16+
}
17+
);
18+
19+
expect( result.status ).toBe( 200 );
20+
expect( result.filecontent ).toInclude( 4 );
21+
expect( result.filecontent ).toInclude( "SUCCESS" );
22+
});
23+
24+
it( "should reject invalid shared key", function() {
25+
var testDir = getDirectoryFromPath( getCurrentTemplatePath() ) & "LDEV0694/";
26+
var physicalPath = testDir & "virtual";
27+
28+
// Use internalRequest with wrong shared key
29+
var result = internalRequest(
30+
template = createURI( "LDEV0694/test-reject.cfm" ),
31+
headers = {
32+
"x-vdirs" = "/vdir,#physicalPath#",
33+
"x-vdirs-sharedkey" = "wrongkey"
34+
}
35+
);
36+
37+
expect( result.filecontent ).toInclude( "FAIL", "Invalid shared key should be rejected" );
38+
});
39+
40+
});
41+
}
42+
43+
private string function createURI( string calledName ) {
44+
var baseURI = "/test/#listLast( getDirectoryFromPath( getCurrentTemplatePath() ), "\/" )#/";
45+
return baseURI & calledName;
46+
}
47+
48+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<cfscript>
2+
// Test that virtual directory is rejected with wrong shared key
3+
try {
4+
include "/vdir/index.cfm";
5+
writeOutput( "FAIL: Virtual directory accessible with wrong key" );
6+
}
7+
catch ( any e ) {
8+
// Expected: should fail to resolve with wrong key
9+
writeOutput( "FAIL: " & e.message );
10+
}
11+
</cfscript>

test/tickets/LDEV0694/test.cfm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<cfscript>
2+
// Test that virtual directory CFML execution works via include
3+
try {
4+
include "/vdir/index.cfm";
5+
writeOutput( "SUCCESS: " );
6+
}
7+
catch ( any e ) {
8+
writeOutput( "FAIL: " & e.message );
9+
}
10+
</cfscript>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<cfoutput>#2*2#</cfoutput>

0 commit comments

Comments
 (0)