Skip to content

Commit 28399d4

Browse files
committed
Register filters & servlets later + CSP improvements
1 parent d35b630 commit 28399d4

5 files changed

Lines changed: 76 additions & 129 deletions

File tree

api/src/org/labkey/api/mcp/McpService.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@
2929
/// ### MCP Development Guide
3030
/// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external
3131
/// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions
32-
/// hosted by LabKey (see `AbstractAgentAction``).
32+
/// hosted by LabKey (see `AbstractAgentAction`).
3333
///
3434
/// ### Adding a new MCP class
3535
/// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module
36-
/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())`
36+
/// 2. Register that class in your module's `startup()` method: `McpService.get().register(new MyMcp())`
3737
/// 3. Add tools and resources
3838
///
3939
/// ### Adding a new MCP tool
@@ -44,13 +44,13 @@
4444
/// permission annotation is required, otherwise your tool will not be registered.**
4545
/// 4. Add `ToolContext` as the first parameter to the method
4646
/// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the
47-
/// default. Again here, the parameter descriptions are very important. Provide examples.
47+
/// default. Again here, the parameter descriptions are very important. Provide examples of parameter values.
4848
/// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User`
4949
/// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User`
5050
/// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate
5151
/// 9. Filter all results to the current container, of course
5252
/// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate
53-
/// failure responses and the LLM client will attempt to correct the problem.
53+
/// failure responses and the LLM client will attempt to correct any problems (hopefully).
5454
/// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring
5555
/// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See
5656
/// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation.
@@ -126,6 +126,7 @@ static void setInstance(McpService service)
126126

127127
boolean isReady();
128128

129+
// Register MCPs in Module.startup()
129130
default void register(McpImpl mcp)
130131
{
131132
try

api/src/org/labkey/api/module/ModuleLoader.java

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -580,19 +580,6 @@ private void doInit(Execution execution) throws ServletException
580580
throw new IllegalStateException("Core module was not first or could not find the Core module. Ensure that Tomcat user can create directories under the <LABKEY_HOME>/modules directory.");
581581
setProjectRoot(coreModule);
582582

583-
for (Module module : modules)
584-
{
585-
module.registerFilters(_servletContext);
586-
}
587-
for (Module module : modules)
588-
{
589-
module.registerServlets(_servletContext);
590-
}
591-
for (Module module : modules)
592-
{
593-
module.registerFinalServlets(_servletContext);
594-
}
595-
596583
// Do this after we've checked to see if we can find the core module. See issue 22797.
597584
verifyProductionModeMatchesBuild();
598585

@@ -790,12 +777,34 @@ public void addStaticWarnings(@NotNull Warnings warnings, boolean showAllWarning
790777
if (!modulesRequiringUpgrade.isEmpty() || !additionalSchemasRequiringUpgrade.isEmpty())
791778
setUpgradeState(UpgradeState.UpgradeRequired);
792779

793-
// Don't accept any requests if we're bootstrapping empty schemas or migrating from SQL Server
780+
// Don't accept any requests if we're bootstrapping empty schemas or doing a database migration
794781
if (!shouldInsertData())
795782
execution = Execution.Synchronous;
796783

797784
startNonCoreUpgradeAndStartup(execution, lockFile);
798785

786+
// Register filters and servlets at the last minute, just before Tomcat starts. At this point, the list of
787+
// modules is final. Also, we've had one case where the CSP filter was getting called before modules were
788+
// initialized, GitHub Issue 1008. We have no idea how that happened, but registering late won't hurt.
789+
790+
_log.info("Registering filters");
791+
792+
for (Module module : _modules)
793+
{
794+
module.registerFilters(_servletContext);
795+
}
796+
797+
_log.info("Registering servlets");
798+
799+
for (Module module : _modules)
800+
{
801+
module.registerServlets(_servletContext);
802+
}
803+
for (Module module : _modules)
804+
{
805+
module.registerFinalServlets(_servletContext);
806+
}
807+
799808
_log.info("LabKey Server startup is complete; {}", execution.getLogMessage());
800809
}
801810

api/src/org/labkey/filters/ContentSecurityPolicyFilter.java

Lines changed: 35 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import org.apache.commons.collections4.multimap.HashSetValuedHashMap;
1313
import org.apache.commons.lang3.EnumUtils;
1414
import org.apache.commons.lang3.StringUtils;
15-
import org.apache.commons.lang3.Strings;
1615
import org.apache.logging.log4j.Logger;
1716
import org.jetbrains.annotations.NotNull;
1817
import org.jetbrains.annotations.Nullable;
@@ -30,7 +29,6 @@
3029
import org.labkey.api.util.StringExpressionFactory;
3130
import org.labkey.api.util.StringExpressionFactory.AbstractStringExpression.NullValueBehavior;
3231
import org.labkey.api.util.logging.LogHelper;
33-
import org.labkey.api.view.ActionURL;
3432

3533
import java.io.IOException;
3634
import java.security.SecureRandom;
@@ -40,13 +38,11 @@
4038
import java.util.HashMap;
4139
import java.util.List;
4240
import java.util.Map;
43-
import java.util.Objects;
4441
import java.util.Set;
4542
import java.util.regex.Matcher;
4643
import java.util.regex.Pattern;
4744
import java.util.stream.Collectors;
4845

49-
5046
/**
5147
* Content Security Policies (CSPs) are loaded from the csp.enforce and csp.report properties in application.properties.
5248
*/
@@ -57,6 +53,9 @@ public class ContentSecurityPolicyFilter implements Filter
5753
private static final String NONCE_SUBST = "REQUEST.SCRIPT.NONCE";
5854
private static final String UPGRADE_INSECURE_REQUESTS_SUBSTITUTION = "UPGRADE.INSECURE.REQUESTS";
5955
private static final String HEADER_NONCE = "org.labkey.filters.ContentSecurityPolicyFilter#NONCE"; // needs to match PageConfig.HEADER_NONCE
56+
private static final String REPORTING_ENDPOINTS_HEADER = "Reporting-Endpoints";
57+
@SuppressWarnings("DataFlowIssue")
58+
private static final String REPORTING_ENDPOINTS_HEADER_VALUE = "csp-report=\"" + PageFlowUtil.urlProvider(AdminUrls.class).getCspReportToURL().getLocalURIString() + "\"";;
6059

6160
private static final Map<ContentSecurityPolicyType, ContentSecurityPolicyFilter> CSP_FILTERS = new CopyOnWriteHashMap<>();
6261

@@ -76,11 +75,10 @@ public class ContentSecurityPolicyFilter implements Filter
7675
private @NotNull String _cspVersion = "Unknown";
7776
// These two are effectively @NotNull since they are set to non-null values in init() and never changed
7877
private String _stashedTemplate = null;
79-
private String _reportToEndpointName = null;
8078

81-
// Per-filter-instance settings are initialized on first request and reset when base server URL or allowed sources
82-
// change. Don't reference this directly; always use ensureSettings().
83-
private volatile @Nullable CspFilterSettings _settings = null;
79+
// Initialized on first request and reset when allowed sources change. Don't reference this directly; always use
80+
// ensurePolicyExpression().
81+
private volatile StringExpression _policyExpression;
8482

8583
public enum ContentSecurityPolicyType
8684
{
@@ -118,7 +116,7 @@ public String getHeaderName()
118116
@Override
119117
public void init(FilterConfig filterConfig) throws ServletException
120118
{
121-
LogHelper.getLogger(ContentSecurityPolicyFilter.class, "CSP filter initialization").info("Initializing {}", filterConfig.getFilterName());
119+
LOG.info("Initializing {}", filterConfig.getFilterName());
122120
Enumeration<String> paramNames = filterConfig.getInitParameterNames();
123121
while (paramNames.hasMoreElements())
124122
{
@@ -146,9 +144,6 @@ else if ("disposition".equalsIgnoreCase(paramName))
146144

147145
if (CSP_FILTERS.put(getType(), this) != null)
148146
throw new ServletException("ContentSecurityPolicyFilter is misconfigured, duplicate policies of type: " + getType());
149-
150-
// configure a different endpoint for each type. TODO: We only need one CSP violation reporting endpoint now, so one header would do
151-
_reportToEndpointName = "csp-" + getType().name().toLowerCase();
152147
}
153148

154149
/** Filter out block comments and replace special characters in the provided policy */
@@ -197,18 +192,21 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
197192
{
198193
if (request instanceof HttpServletRequest req && response instanceof HttpServletResponse resp)
199194
{
200-
CspFilterSettings settings = ensureSettings();
195+
StringExpression expression = ensurePolicyExpression();
201196

202197
if (getType() != ContentSecurityPolicyType.Enforce || !OptionalFeatureService.get().isFeatureEnabled(FEATURE_FLAG_DISABLE_ENFORCE_CSP))
203198
{
204199
Map<String, String> map = Map.of(NONCE_SUBST, getScriptNonceHeader(req));
205-
var csp = settings.getPolicyExpression().eval(map);
206-
resp.setHeader(getType().getHeaderName(), csp);
200+
String csp = expression.eval(map);
201+
202+
if ("https".equals(req.getScheme()))
203+
{
204+
if (resp.getHeader(REPORTING_ENDPOINTS_HEADER) == null)
205+
resp.addHeader(REPORTING_ENDPOINTS_HEADER, REPORTING_ENDPOINTS_HEADER_VALUE);
206+
csp = csp + " report-to csp-report ;";
207+
}
207208

208-
// null if https: is not configured on this server
209-
String reportingEndpointsHeaderValue = settings.getReportingEndpointsHeaderValue();
210-
if (reportingEndpointsHeaderValue != null)
211-
resp.addHeader("Reporting-Endpoints", reportingEndpointsHeaderValue);
209+
resp.setHeader(getType().getHeaderName(), csp);
212210
}
213211
}
214212
chain.doFilter(request, response);
@@ -229,91 +227,26 @@ public String getStashedTemplate()
229227
return _stashedTemplate;
230228
}
231229

232-
public String getReportToEndpointName()
233-
{
234-
return _reportToEndpointName;
235-
}
236-
237-
private void clearSettings()
230+
private void clearPolicyExpression()
238231
{
239-
_settings = null;
232+
_policyExpression = null;
240233
}
241234

242-
private @NotNull CspFilterSettings ensureSettings()
235+
private @NotNull StringExpression ensurePolicyExpression()
243236
{
244-
String baseServerUrl = AppProps.getInstance().getBaseServerUrl();
245-
CspFilterSettings settings = _settings; // Stash a local copy to ensure consistency in the checks below
237+
StringExpression expression = _policyExpression;
246238

247-
// Reset settings if null or if base server URL has changed
248-
if (null == settings || !Objects.equals(baseServerUrl, settings.getPreviousBaseServerUrl()))
239+
if (expression == null)
249240
{
250-
settings = _settings = new CspFilterSettings(this, baseServerUrl);
251-
}
252-
253-
return settings;
254-
}
255-
256-
// Hold all the mutable per-filter settings in a single object so they can be set atomically
257-
private static class CspFilterSettings
258-
{
259-
private final String _policyTemplate;
260-
private final String _reportingEndpointsHeaderValue;
261-
private final String _previousBaseServerUrl;
262-
private final StringExpression _policyExpression;
263-
264-
private CspFilterSettings(ContentSecurityPolicyFilter filter, String baseServerUrl)
265-
{
266-
// Add "Reporting-Endpoints" header and "report-to" directive only if https: is configured on this
267-
// server. This ensures that browsers fall-back on report-uri if https: isn't configured.
268-
if (Strings.CI.startsWith(baseServerUrl, "https://"))
269-
{
270-
// Each filter adds its own "Reporting-Endpoints" header since we want to convey the correct version (eXX vs. rXX)
271-
@SuppressWarnings("DataFlowIssue")
272-
ActionURL violationUrl = PageFlowUtil.urlProvider(AdminUrls.class).getCspReportToURL();
273-
// Use an absolute URL so we always post to https:, even if the violating request uses http:
274-
_reportingEndpointsHeaderValue = filter.getReportToEndpointName() + "=\"" + violationUrl.getURIString() + "\"";
275-
276-
// Add "report-to" directive to the policy
277-
_policyTemplate = filter.getStashedTemplate() + " report-to " + filter.getReportToEndpointName() + " ;";
278-
}
279-
else
280-
{
281-
_policyTemplate = filter.getStashedTemplate();
282-
_reportingEndpointsHeaderValue = null;
283-
}
284-
285-
_previousBaseServerUrl = baseServerUrl;
286-
287-
final String substitutedPolicy;
288-
289241
synchronized (SUBSTITUTION_LOCK)
290242
{
291-
substitutedPolicy = StringExpressionFactory.create(_policyTemplate, false, NullValueBehavior.KeepSubstitution)
243+
var substitutedPolicy = StringExpressionFactory.create(getStashedTemplate(), false, NullValueBehavior.KeepSubstitution)
292244
.eval(SUBSTITUTION_MAP);
245+
expression = _policyExpression = StringExpressionFactory.create(substitutedPolicy, false, NullValueBehavior.ReplaceNullAndMissingWithBlank);
293246
}
294-
295-
_policyExpression = StringExpressionFactory.create(substitutedPolicy, false, NullValueBehavior.ReplaceNullAndMissingWithBlank);
296-
}
297-
298-
public String getPolicyTemplate()
299-
{
300-
return _policyTemplate;
301247
}
302248

303-
public String getReportingEndpointsHeaderValue()
304-
{
305-
return _reportingEndpointsHeaderValue;
306-
}
307-
308-
public String getPreviousBaseServerUrl()
309-
{
310-
return _previousBaseServerUrl;
311-
}
312-
313-
public StringExpression getPolicyExpression()
314-
{
315-
return _policyExpression;
316-
}
249+
return expression;
317250
}
318251

319252
public static String getScriptNonceHeader(HttpServletRequest request)
@@ -362,7 +295,7 @@ public static void unregisterAllowedSources(String key, Directive directive)
362295

363296
/**
364297
* Regenerate the substitution map on every register/unregister. The policy expression will be regenerated on the
365-
* next request (see {@link #ensureSettings()}).
298+
* next request (see {@link #ensurePolicyExpression()}).
366299
*/
367300
public static void regenerateSubstitutionMap()
368301
{
@@ -389,7 +322,7 @@ public static void regenerateSubstitutionMap()
389322

390323
// Tell each registered ContentSecurityPolicyFilter to clear its settings so the next request recreates them
391324
// using the new substitution map
392-
CSP_FILTERS.values().forEach(ContentSecurityPolicyFilter::clearSettings);
325+
CSP_FILTERS.values().forEach(ContentSecurityPolicyFilter::clearPolicyExpression);
393326
}
394327
}
395328

@@ -436,7 +369,7 @@ public static List<String> getMissingSubstitutions(ContentSecurityPolicyType typ
436369
}
437370
else
438371
{
439-
String template = filter.ensureSettings().getPolicyTemplate();
372+
String template = filter.getStashedTemplate();
440373
ret = Arrays.stream(Directive.values())
441374
.map(dir -> "${" + dir.getSubstitutionKey() + "}")
442375
.filter(key -> !template.contains(key))
@@ -451,13 +384,15 @@ public static void registerMetricsProvider()
451384
UsageMetricsService.get().registerUsageMetrics("API", () -> Map.of("cspFilters", CSP_FILTERS.values().stream()
452385
.collect(Collectors.toMap(ContentSecurityPolicyFilter::getType,
453386
filter -> {
454-
CspFilterSettings settings = filter.ensureSettings();
387+
StringExpression expression = filter.ensurePolicyExpression();
455388
return Map.of(
456389
"version", filter.getCspVersion(),
457-
"csp", settings.getPolicyTemplate(),
458-
"cspSubstituted", settings.getPolicyExpression().getSource()
390+
"csp", filter.getStashedTemplate(),
391+
"cspSubstituted", expression.getSource()
459392
);
460-
}))));
393+
}))
394+
)
395+
);
461396
}
462397

463398
public static class TestCase extends Assert
@@ -630,7 +565,7 @@ public void testSubstitutionMap()
630565
private void verifySubstitutionInPolicyExpressions(String value, int expectedCount)
631566
{
632567
List<String> failures = CSP_FILTERS.values().stream()
633-
.map(filter -> filter.ensureSettings().getPolicyExpression().eval(Map.of()))
568+
.map(filter -> filter.ensurePolicyExpression().eval(Map.of()))
634569
.filter(policy -> StringUtils.countMatches(policy, value) != expectedCount)
635570
.toList();
636571

core/src/org/labkey/core/CoreModule.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,6 @@ public QuerySchema createSchema(DefaultSchema schema, Module module)
559559

560560
ScriptEngineManagerImpl.registerEncryptionMigrationHandler();
561561

562-
McpService.get().register(new CoreMcp());
563562
PostgreSqlService.setInstance(PostgreSqlDialectFactory::getLatestSupportedDialect);
564563

565564
deleteTempFiles();
@@ -1283,6 +1282,8 @@ public void moduleStartupComplete(ServletContext servletContext)
12831282
UserManager.addUserListener(new EmailPreferenceUserListener());
12841283

12851284
Encryption.checkMigration();
1285+
1286+
McpService.get().register(new CoreMcp());
12861287
}
12871288

12881289
// Issue 7527: Auto-detect missing SQL views and attempt to recreate

0 commit comments

Comments
 (0)