Skip to content

Commit f88cf73

Browse files
authored
Conditionally add upgrade-insecure-requests CSP directive (#6665)
1 parent 7534a1d commit f88cf73

2 files changed

Lines changed: 36 additions & 32 deletions

File tree

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

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,17 @@ public class ContentSecurityPolicyFilter implements Filter
4949

5050
private static final String NONCE_SUBST = "REQUEST.SCRIPT.NONCE";
5151
private static final String REPORT_PARAMETER_SUBSTITUTION = "CSP.REPORT.PARAMS";
52+
private static final String UPGRADE_INSECURE_REQUESTS_SUBSTITUTION = "UPGRADE.INSECURE.REQUESTS";
5253
private static final String HEADER_NONCE = "org.labkey.filters.ContentSecurityPolicyFilter#NONCE"; // needs to match PageConfig.HEADER_NONCE
5354

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

5657
// Lock that protects the static data structures below
57-
private static final Object ALLOWED_SOURCES_LOCK = new Object();
58+
private static final Object SUBSTITUTION_LOCK = new Object();
5859
private static final Map<Directive, SetValuedMap<String, String>> ALLOWED_SOURCES = new HashMap<>();
5960
// Regenerate and stash on every "allowed source" change as a convenience (so every filter doesn't need to recalculate
6061
// it on every init() and change)
61-
private static Map<String, String> ALLOWED_SOURCES_SUBSTITUTION_MAP = Collections.emptyMap();
62+
private static Map<String, String> SUBSTITUTION_MAP = Collections.emptyMap();
6263

6364
// Per-filter-instance parameters that are set in init() and never changed
6465
private ContentSecurityPolicyType _type = ContentSecurityPolicyType.Enforce;
@@ -89,7 +90,7 @@ public String getHeaderName()
8990

9091
static
9192
{
92-
// ReactJS hot reload uses localhost port 3001. If in dev mode, allow browser to access that port for fonts
93+
// ReactJS hot reload uses localhost port 3001. If in dev mode, allow the browser to access that port for fonts
9394
// and connections.
9495
if (AppProps.getInstance().isDevMode())
9596
{
@@ -203,10 +204,10 @@ private void regeneratePolicyExpression()
203204
{
204205
final String allowSubstitutedPolicy;
205206

206-
synchronized (ALLOWED_SOURCES_LOCK)
207+
synchronized (SUBSTITUTION_LOCK)
207208
{
208209
allowSubstitutedPolicy = StringExpressionFactory.create(_policyTemplate, false, NullValueBehavior.KeepSubstitution)
209-
.eval(ALLOWED_SOURCES_SUBSTITUTION_MAP);
210+
.eval(SUBSTITUTION_MAP);
210211
}
211212

212213
_policyExpression = StringExpressionFactory.create(allowSubstitutedPolicy, false, NullValueBehavior.ReplaceNullAndMissingWithBlank);
@@ -250,7 +251,7 @@ public static void registerAllowedSources(Directive directive, String key, Strin
250251

251252
public static void registerAllowedSources(String key, Directive directive, String... allowedSources)
252253
{
253-
synchronized (ALLOWED_SOURCES_LOCK)
254+
synchronized (SUBSTITUTION_LOCK)
254255
{
255256
if (allowedSources.length == 0)
256257
throw new IllegalStateException("Registering no sources is not allowed");
@@ -263,7 +264,7 @@ public static void registerAllowedSources(String key, Directive directive, Strin
263264

264265
public static void unregisterAllowedSources(Directive directive, String key)
265266
{
266-
synchronized (ALLOWED_SOURCES_LOCK)
267+
synchronized (SUBSTITUTION_LOCK)
267268
{
268269
LOG.debug("Unregistering {} for {}", directive, key);
269270
SetValuedMap<String, String> multiMap = ALLOWED_SOURCES.get(directive);
@@ -278,11 +279,11 @@ public static void unregisterAllowedSources(Directive directive, String key)
278279
}
279280

280281
// Regenerate the substitution map and all policy expressions on every register/unregister
281-
private static void regenerateSubstitutionMap()
282+
public static void regenerateSubstitutionMap()
282283
{
283-
synchronized (ALLOWED_SOURCES_LOCK)
284+
synchronized (SUBSTITUTION_LOCK)
284285
{
285-
ALLOWED_SOURCES_SUBSTITUTION_MAP = ALLOWED_SOURCES.entrySet().stream()
286+
SUBSTITUTION_MAP = ALLOWED_SOURCES.entrySet().stream()
286287
.filter(e -> !e.getValue().isEmpty())
287288
.collect(Collectors.toMap(
288289
e -> e.getKey().getSubstitutionKey(),
@@ -294,12 +295,9 @@ private static void regenerateSubstitutionMap()
294295
// Add an empty substitution for sources that lack registrations. This strips them from the stashed policy,
295296
// meaning less work on every request.
296297
Arrays.stream(Directive.values())
297-
.forEach(dir -> ALLOWED_SOURCES_SUBSTITUTION_MAP.putIfAbsent(dir.getSubstitutionKey(), ""));
298+
.forEach(dir -> SUBSTITUTION_MAP.putIfAbsent(dir.getSubstitutionKey(), ""));
298299

299-
// Backward compatibility for CSPs using old substitution key
300-
// TODO: Remove in 25.4 and adjust the junit test below
301-
if (ALLOWED_SOURCES_SUBSTITUTION_MAP.containsKey(Directive.Connection.getSubstitutionKey()))
302-
ALLOWED_SOURCES_SUBSTITUTION_MAP.put("LABKEY.ALLOWED.CONNECTIONS", ALLOWED_SOURCES_SUBSTITUTION_MAP.get(Directive.Connection.getSubstitutionKey()));
300+
SUBSTITUTION_MAP.put(UPGRADE_INSECURE_REQUESTS_SUBSTITUTION, AppProps.getInstance().isSSLRequired() ? "upgrade-insecure-requests;" : "");
303301

304302
// Tell each registered ContentSecurityPolicyFilter to refresh its policy template based on the new substitution map
305303
CSP_FILTERS.values().forEach(ContentSecurityPolicyFilter::regeneratePolicyExpression);
@@ -378,13 +376,13 @@ public void testPolicyFiltering()
378376
@Test
379377
public void testSubstitutionMap()
380378
{
381-
synchronized (ALLOWED_SOURCES_LOCK)
379+
synchronized (SUBSTITUTION_LOCK)
382380
{
383-
// Ensure substitution map has been initialized, otherwise the finally block asserts will fail
381+
// Ensure the substitution map has been initialized; otherwise the finally block asserts will fail
384382
regenerateSubstitutionMap();
385383
// Make a deep copy of ALLOWED_SOURCES so we can restore it after testing
386384
int sourceMapSize = ALLOWED_SOURCES.size();
387-
int substitutionMapSize = ALLOWED_SOURCES_SUBSTITUTION_MAP.size();
385+
int substitutionMapSize = SUBSTITUTION_MAP.size();
388386
Map<Directive, SetValuedMap<String, String>> savedSources = ALLOWED_SOURCES.entrySet().stream()
389387
.collect(Collectors.toMap(Map.Entry::getKey, e -> new HashSetValuedHashMap<>(e.getValue())));
390388

@@ -408,53 +406,53 @@ public void testSubstitutionMap()
408406
verifySubstitutionMapSize(0);
409407
registerAllowedSources("foo", Directive.Connection, "MySource");
410408
assertEquals(1, ALLOWED_SOURCES.size());
411-
verifySubstitutionMapSize(2); // Old connection substitution key should be added as well
409+
verifySubstitutionMapSize(1);
412410
verifySubstitutionInPolicyExpressions("MySource", 1);
413411
registerAllowedSources("bar", Directive.Connection, "MySource");
414412
assertEquals(1, ALLOWED_SOURCES.size());
415-
verifySubstitutionMapSize(2);
413+
verifySubstitutionMapSize(1);
416414
verifySubstitutionInPolicyExpressions("MySource", 1); // Duplicate source should be filtered out
417415

418416
unregisterAllowedSources(Directive.Font, "font");
419417
registerAllowedSources("font", Directive.Font, "MySource");
420418
assertEquals(2, ALLOWED_SOURCES.size());
421-
verifySubstitutionMapSize(3);
419+
verifySubstitutionMapSize(2);
422420
verifySubstitutionInPolicyExpressions("MySource", 2);
423421
registerAllowedSources("font2", Directive.Font, "MyFontSource");
424422
assertEquals(2, ALLOWED_SOURCES.size());
425-
verifySubstitutionMapSize(3);
423+
verifySubstitutionMapSize(2);
426424
verifySubstitutionInPolicyExpressions("MySource", 2);
427425
verifySubstitutionInPolicyExpressions("MyFontSource", 1);
428426
unregisterAllowedSources(Directive.Font, "font2");
429427
assertEquals(2, ALLOWED_SOURCES.size());
430-
verifySubstitutionMapSize(3);
428+
verifySubstitutionMapSize(2);
431429
verifySubstitutionInPolicyExpressions("MySource", 2);
432430
verifySubstitutionInPolicyExpressions("MyFontSource", 0);
433431
unregisterAllowedSources(Directive.Font, "font");
434432
assertEquals(2, ALLOWED_SOURCES.size()); // Font entry still exists, but should be empty
435433
assertTrue(ALLOWED_SOURCES.get(Directive.Font).isEmpty());
436-
verifySubstitutionMapSize(2);// Back to the way it was
434+
verifySubstitutionMapSize(1);// Back to the way it was
437435
verifySubstitutionInPolicyExpressions("MySource", 1);
438436
verifySubstitutionInPolicyExpressions("MyFontSource", 0);
439437

440438
unregisterAllowedSources(Directive.Frame, "frame");
441439
registerAllowedSources("frame", Directive.Frame, "FrameSource", "FrameStore");
442440
assertEquals(3, ALLOWED_SOURCES.size());
443-
verifySubstitutionMapSize(3);
441+
verifySubstitutionMapSize(2);
444442
verifySubstitutionInPolicyExpressions("FrameSource", 1);
445443
verifySubstitutionInPolicyExpressions("FrameStore", 1);
446444

447445
unregisterAllowedSources(Directive.Style, "style");
448446
registerAllowedSources("style", Directive.Style, "StyleSource", "MoreStylishStore");
449447
assertEquals(4, ALLOWED_SOURCES.size());
450-
verifySubstitutionMapSize(4);
448+
verifySubstitutionMapSize(3);
451449
verifySubstitutionInPolicyExpressions("StyleSource", 1);
452450
verifySubstitutionInPolicyExpressions("MoreStylishStore", 1);
453451

454452
unregisterAllowedSources(Directive.Image, "image");
455453
registerAllowedSources("image", Directive.Image, "ImageSource", "BetterImageStore");
456454
assertEquals(5, ALLOWED_SOURCES.size());
457-
verifySubstitutionMapSize(5);
455+
verifySubstitutionMapSize(4);
458456
verifySubstitutionInPolicyExpressions("ImageSource", 1);
459457
verifySubstitutionInPolicyExpressions("BetterImageStore", 1);
460458
}
@@ -465,7 +463,7 @@ public void testSubstitutionMap()
465463
ALLOWED_SOURCES.putAll(savedSources);
466464
regenerateSubstitutionMap();
467465
assertEquals(sourceMapSize, ALLOWED_SOURCES.size());
468-
assertEquals(substitutionMapSize, ALLOWED_SOURCES_SUBSTITUTION_MAP.size());
466+
assertEquals(substitutionMapSize, SUBSTITUTION_MAP.size());
469467
}
470468
}
471469
}
@@ -485,11 +483,13 @@ private void verifySubstitutionInPolicyExpressions(String value, int expectedCou
485483

486484
private void verifySubstitutionMapSize(long expectedNonEmptyValues)
487485
{
488-
// Actual map size should stay static throughout test
489-
int expectedSubstitutionMapSize = Directive.values().length + 1; // One extra for old "connections" key
486+
// Actual map size should stay static throughout the test
487+
int expectedSubstitutionMapSize = Directive.values().length + 1; // One extra for UPGRADE.INSECURE.REQUESTS
488+
if (AppProps.getInstance().isSSLRequired())
489+
expectedNonEmptyValues++;
490490

491-
assertEquals(expectedSubstitutionMapSize, ALLOWED_SOURCES_SUBSTITUTION_MAP.size());
492-
long nonEmptyValues = ALLOWED_SOURCES_SUBSTITUTION_MAP.entrySet().stream().filter(e -> !e.getValue().isEmpty()).count();
491+
assertEquals(expectedSubstitutionMapSize, SUBSTITUTION_MAP.size());
492+
long nonEmptyValues = SUBSTITUTION_MAP.entrySet().stream().filter(e -> !e.getValue().isEmpty()).count();
493493
assertEquals(expectedNonEmptyValues, nonEmptyValues);
494494
}
495495
}

core/src/org/labkey/core/admin/AdminController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@
327327
import org.labkey.core.security.BlockListFilter;
328328
import org.labkey.core.security.SecurityController;
329329
import org.labkey.data.xml.TablesDocument;
330+
import org.labkey.filters.ContentSecurityPolicyFilter;
330331
import org.labkey.security.xml.GroupEnumType;
331332
import org.springframework.mock.web.MockHttpServletResponse;
332333
import org.springframework.validation.BindException;
@@ -1341,6 +1342,7 @@ public boolean handlePost(SiteSettingsForm form, BindException errors) throws Ex
13411342
props.setPipelineToolsDir(form.getPipelineToolsDirectory());
13421343
props.setNavAccessOpen(form.isNavAccessOpen());
13431344
props.setSSLRequired(form.isSslRequired());
1345+
boolean sslSettingChanged = AppProps.getInstance().isSSLRequired() != form.isSslRequired();
13441346
props.setSSLPort(form.getSslPort());
13451347
props.setMemoryUsageDumpInterval(form.getMemoryUsageDumpInterval());
13461348
props.setReadOnlyHttpRequestTimeout(form.getReadOnlyHttpRequestTimeout());
@@ -1415,6 +1417,8 @@ public boolean handlePost(SiteSettingsForm form, BindException errors) throws Ex
14151417

14161418
props.save(getViewContext().getUser());
14171419
UsageReportingLevel.reportNow();
1420+
if (sslSettingChanged)
1421+
ContentSecurityPolicyFilter.regenerateSubstitutionMap();
14181422

14191423
return true;
14201424
}

0 commit comments

Comments
 (0)