Sanitize log input

This commit is contained in:
2026-01-05 18:20:12 +01:00
parent 68b23ee190
commit 7c0b3eaff1
8 changed files with 41 additions and 25 deletions

View File

@@ -103,6 +103,7 @@ public class SchemaMigration implements ApplicationRunner {
return; return;
} }
// SECURITY: This is ok, because tableName and columnName are controlled internally and not from user input.
try { try {
jdbcTemplate.execute( jdbcTemplate.execute(
"ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue

View File

@@ -1,5 +1,9 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -16,19 +20,15 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfException; import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils; import org.springframework.web.util.WebUtils;
import org.springframework.web.client.RestTemplate;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -85,10 +85,12 @@ public class SecurityConfig {
) )
.logout((logout) -> logout.logoutSuccessUrl("/").permitAll()) .logout((logout) -> logout.logoutSuccessUrl("/").permitAll())
.exceptionHandling((exceptions) -> .exceptionHandling((exceptions) ->
exceptions.defaultAuthenticationEntryPointFor( exceptions
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), .defaultAuthenticationEntryPointFor(
new AntPathRequestMatcher("/api/**") new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
).accessDeniedHandler(csrfAccessDeniedHandler()) new AntPathRequestMatcher("/api/**")
)
.accessDeniedHandler(csrfAccessDeniedHandler())
) )
.csrf((csrf) -> .csrf((csrf) ->
csrf csrf

View File

@@ -124,7 +124,7 @@ public class ChannelApiController {
LOG.warn( LOG.warn(
"No authorized Twitch client found for {} while fetching admin suggestions for {}", "No authorized Twitch client found for {} while fetching admin suggestions for {}",
sessionUsername, sessionUsername,
broadcaster broadcaster.replaceAll("[\n\r]", "_")
); );
return List.of(); return List.of();
} }
@@ -185,7 +185,7 @@ public class ChannelApiController {
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info( LOG.info(
"Updating canvas for {} by {}: {}x{}", "Updating canvas for {} by {}: {}x{}",
broadcaster, broadcaster.replaceAll("[\n\r]", "_"),
sessionUsername, sessionUsername,
request.getWidth(), request.getWidth(),
request.getHeight() request.getHeight()
@@ -276,7 +276,7 @@ public class ChannelApiController {
LOG.info( LOG.info(
"Updating visibility for asset {} on {} by {} to hidden={} ", "Updating visibility for asset {} on {} by {} to hidden={} ",
assetId, assetId,
broadcaster, broadcaster.replaceAll("[\n\r]", "_"),
sessionUsername, sessionUsername,
request.isHidden() request.isHidden()
); );
@@ -284,7 +284,12 @@ public class ChannelApiController {
.updateVisibility(broadcaster, assetId, request) .updateVisibility(broadcaster, assetId, request)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> { .orElseThrow(() -> {
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); LOG.warn(
"Visibility update for missing asset {} on {} by {}",
assetId.replaceAll("[\n\r]", "_"),
broadcaster.replaceAll("[\n\r]", "_"),
sessionUsername
);
return new ResponseStatusException(NOT_FOUND, "Asset not found"); return new ResponseStatusException(NOT_FOUND, "Asset not found");
}); });
} }

View File

@@ -95,7 +95,11 @@ public class ViewController {
broadcaster, broadcaster,
sessionUsername sessionUsername
); );
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername); LOG.info(
"Rendering admin console for {} (requested by {})",
broadcaster.replaceAll("[\n\r]", "_"),
sessionUsername
);
Settings settings = settingsService.get(); Settings settings = settingsService.get();
model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", sessionUsername); model.addAttribute("username", sessionUsername);

View File

@@ -51,6 +51,7 @@ public class AuthorizationService {
String broadcaster, String broadcaster,
String sessionUsername String sessionUsername
) { ) {
broadcaster = broadcaster.replaceAll("[\n\r]", "_");
if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) { if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) {
LOG.warn( LOG.warn(
"Access denied for broadcaster/admin-only action by user {} on broadcaster {}", "Access denied for broadcaster/admin-only action by user {} on broadcaster {}",

View File

@@ -129,11 +129,14 @@ public class ChannelDirectoryService {
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException { public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
long fileSize = file.getSize(); long fileSize = file.getSize();
long maxSize = uploadLimitBytes; if (fileSize > uploadLimitBytes) {
if (fileSize > maxSize) {
throw new ResponseStatusException( throw new ResponseStatusException(
PAYLOAD_TOO_LARGE, PAYLOAD_TOO_LARGE,
String.format("Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", fileSize, maxSize) String.format(
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
uploadLimitBytes
)
); );
} }
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);

View File

@@ -349,8 +349,8 @@
</div> </div>
</div> </div>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ''; const broadcaster = /*[[${broadcaster}]]*/ "";
const username = /*[[${username}]]*/ ''; const username = /*[[${username}]]*/ "";
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0; const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
const SETTINGS = JSON.parse(/*[[${settingsJson}]]*/); const SETTINGS = JSON.parse(/*[[${settingsJson}]]*/);
</script> </script>

View File

@@ -115,9 +115,9 @@ class ChannelApiIntegrationTest {
mockMvc mockMvc
.perform( .perform(
delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId).with( delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId)
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)) .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
).with(csrf()) .with(csrf())
) )
.andExpect(status().isOk()); .andExpect(status().isOk());
} }