Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ public class JarUploadService {
/** Allowed characters in job name and version used as path components. */
private static final Pattern SAFE_NAME = Pattern.compile("[a-zA-Z0-9._\\-]+");

/**
* Fully-qualified Java class name pattern.
* Matches names like {@code com.fronzec.plugins.ticketpdf.TicketPdfJobPlugin}.
* Requires at least one package segment followed by a simple class name.
*/
private static final Pattern FQCN =
Pattern.compile("([a-zA-Z_$][a-zA-Z0-9_$]*\\.)+[a-zA-Z_$][a-zA-Z0-9_$]*");

private final JobDefinitionRepository jobDefinitionRepository;
private final Optional<AutoApproveConfig> autoApproveConfig;
private final JarSignatureVerifier jarSignatureVerifier;
Expand Down Expand Up @@ -90,6 +98,7 @@ public JarUploadResponse uploadJar(
try {

validateJar(file);
validateMainClassName(mainClassName);

String checksum = ChecksumUtil.computeSha256(file);

Expand Down Expand Up @@ -172,6 +181,28 @@ private void validateJar(MultipartFile file) {
}
}

/**
* Validates that {@code mainClassName} is a fully-qualified Java class name.
*
* <p>Requires at least one package segment (e.g. {@code com.example.MyClass}).
* A bare simple name ({@code MyClass}) is rejected because a plugin entry-point
* without a package would conflict with the default package and is almost always
* an input error.
*
* @param mainClassName the class name to validate
* @throws InvalidJarException if the value is blank or does not match the FQCN pattern
*/
private void validateMainClassName(String mainClassName) {
if (mainClassName == null || mainClassName.isBlank()) {
throw new InvalidJarException("mainClassName must not be blank");
}
if (!FQCN.matcher(mainClassName).matches()) {
throw new InvalidJarException(
"mainClassName must be a fully-qualified Java class name "
+ "(e.g. com.example.MyClass): " + mainClassName);
}
Comment on lines +199 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid reflecting raw mainClassName in exception messages.

At Line 201-203, the exception embeds user-controlled input directly. That message is later logged by GlobalExceptionHandler, so CR/LF payloads can forge log lines.

Suggested fix
         if (!FQCN.matcher(mainClassName).matches()) {
             throw new InvalidJarException(
                 "mainClassName must be a fully-qualified Java class name "
-                    + "(e.g. com.example.MyClass): " + mainClassName);
+                    + "(e.g. com.example.MyClass)");
         }

As per coding guidelines, use the established logging framework safely and avoid exposing unsafe user-provided content in error/log paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/service/JarUploadService.java`
around lines 199 - 203, The InvalidJarException being thrown in the
JarUploadService contains user-controlled input (mainClassName) directly
embedded in the exception message, which poses a security risk as this message
is later logged by GlobalExceptionHandler and can be exploited for log injection
attacks via CR/LF payloads. Remove the mainClassName variable from the exception
message in the throw statement around lines 201-203, keeping only the
descriptive portion about the fully-qualified Java class name requirement, and
avoid concatenating any user-provided values directly into exception messages.

Source: Coding guidelines

}

/** Reads up to {@code n} bytes from the beginning of the file. */
private byte[] readHeader(MultipartFile file, int n) {
try (InputStream in = file.getInputStream()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
* <li>{@code GET /jobs/plugins} — public (plugin discovery)</li>
* <li>{@code GET /jobs/**} — {@code PLUGIN_VIEWER} or {@code PLUGIN_ADMIN}</li>
* <li>{@code POST /jobs/**}, {@code PUT /jobs/**}, {@code DELETE /jobs/**} — {@code PLUGIN_ADMIN}</li>
* <li>{@code POST /data/**} — {@code PLUGIN_ADMIN} (destructive data-reset; not active in production)</li>
* <li>{@code /actuator/health} — public</li>
* </ul>
*
Expand Down Expand Up @@ -64,6 +65,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.hasAnyRole("PLUGIN_VIEWER", "PLUGIN_ADMIN")
.requestMatchers("/jobs/**")
.hasRole("PLUGIN_ADMIN")
.requestMatchers("/data/**")
.hasRole("PLUGIN_ADMIN")
.anyRequest()
.authenticated())
.httpBasic(Customizer.withDefaults());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,35 @@
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* Externalised security credentials from {@code application.properties}.
* Externalised security credentials bound from {@code app.security.*} properties.
*
* <p>Note: Passwords are stored in plain text ({@code {noop}}) for development.
* Migrate to {@code {bcrypt}} hashes before production deployment.
* <p>Passwords have NO Java-level default. Each active profile must supply its own credentials:
* <ul>
* <li>{@code local} / {@code docker}: plain-text {@code admin123} / {@code viewer123}</li>
* <li>{@code production}: bcrypt hashes via env vars {@code APP_SECURITY_ADMIN_PASSWORD}
* and {@code APP_SECURITY_VIEWER_PASSWORD}</li>
* </ul>
*
* <p>This is deliberate: a missing password is fail-safe. With no active profile (a
* misconfiguration) the password is {@code null}, {@link SecurityConfig} fails to build the
* {@code UserDetails} ({@code password cannot be null}), and the app refuses to start rather
* than booting with a known insecure default. Tests that load {@link SecurityConfig} in
* isolation must supply explicit credentials via the context runner's property values.
*/
@ConfigurationProperties("app.security")
public class SecurityProperties {

/** Username for the admin role (PLUGIN_ADMIN). */
/** Username for the admin role (PLUGIN_ADMIN). Defaults to {@code "admin"}. */
private String adminUser = "admin";

/** Password for the admin user — plain text in dev, bcrypt hash in production. */
private String adminPassword = "{noop}admin123";
/** Password for the admin user. No default — supplied per active profile (fail-safe if absent). */
private String adminPassword;

/** Username for the viewer role (PLUGIN_VIEWER). */
/** Username for the viewer role (PLUGIN_VIEWER). Defaults to {@code "viewer"}. */
private String viewerUser = "viewer";

/** Password for the viewer user — plain text in dev, bcrypt hash in production. */
private String viewerPassword = "{noop}viewer123";
/** Password for the viewer user. No default — supplied per active profile (fail-safe if absent). */
private String viewerPassword;

public String getAdminUser() {
return adminUser;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,20 @@ public ResponseEntity<?> disableDefinition(@PathVariable long id) {
* Approves a job definition, setting its status to {@code APPROVED} and recording
* the approver and timestamp.
*
* <p>The approver identity is always derived from the authenticated principal
* (via {@link AuditService#currentUserId()}). Any {@code approvedBy} field
* supplied in the request body is accepted for backward compatibility with existing
* scripts (e.g. {@code load-plugins.hurl}) but is intentionally ignored — it
* cannot be trusted because callers can forge any value.
*
* <p>Returns {@code 200 OK} with the updated definition on success.
* {@code 404 Not Found} if the definition does not exist.
* {@code 409 Conflict} if the definition is already approved.
*/
@PutMapping("/definitions/{id}/approve")
public ResponseEntity<?> approveDefinition(
@PathVariable long id,
@RequestBody ApprovalRequest request) {
@RequestBody(required = false) ApprovalRequest request) {
Optional<JobDefinitionEntity> opt = jobDefinitionRepository.findById(id);
if (opt.isEmpty()) {
log.warn("Cannot approve — job definition not found: id={}", id);
Expand All @@ -226,22 +232,25 @@ public ResponseEntity<?> approveDefinition(
"message", "Job definition " + id + " is already approved"));
}

// Derive the approver from the authenticated principal, not from the request body.
// The body field is kept for backward-compat deserialization but its value is discarded.
String approver = AuditService.currentUserId();
entity.setApprovalStatus("APPROVED");
entity.setApprovedBy(request.approvedBy());
entity.setApprovedBy(approver);
entity.setApprovedAt(LocalDateTime.now());
JobDefinitionEntity saved = jobDefinitionRepository.save(entity);
log.info(
"Job definition approved: id={}, jobName={}, approvedBy={}",
saved.getId(),
saved.getJobName(),
request.approvedBy());
approver);

auditService.logEvent(
new AuditEvent(
AuditEventType.JOB_APPROVED,
saved.getJobName(),
AuditService.currentUserId(),
"Approved by " + request.approvedBy(),
approver,
"Approved by " + approver,
AuditEvent.SUCCESS,
LocalDateTime.now()));

Expand All @@ -252,36 +261,44 @@ public ResponseEntity<?> approveDefinition(
* Rejects a job definition, setting its status to {@code REJECTED} and recording
* the reviewer and timestamp.
*
* <p>The reviewer identity is always derived from the authenticated principal
* (via {@link AuditService#currentUserId()}). Any {@code approvedBy} field
* supplied in the request body is accepted for backward compatibility but is
* intentionally ignored — it cannot be trusted because callers can forge any value.
*
* <p>Returns {@code 200 OK} with the updated definition on success.
* {@code 404 Not Found} if the definition does not exist.
*/
@PutMapping("/definitions/{id}/reject")
public ResponseEntity<?> rejectDefinition(
@PathVariable long id,
@RequestBody ApprovalRequest request) {
@RequestBody(required = false) ApprovalRequest request) {
Optional<JobDefinitionEntity> opt = jobDefinitionRepository.findById(id);
if (opt.isEmpty()) {
log.warn("Cannot reject — job definition not found: id={}", id);
return ResponseEntity.notFound().build();
}

// Derive the reviewer from the authenticated principal, not from the request body.
// The body field is kept for backward-compat deserialization but its value is discarded.
String reviewer = AuditService.currentUserId();
JobDefinitionEntity entity = opt.get();
entity.setApprovalStatus("REJECTED");
entity.setApprovedBy(request.approvedBy());
entity.setApprovedBy(reviewer);
entity.setApprovedAt(LocalDateTime.now());
JobDefinitionEntity saved = jobDefinitionRepository.save(entity);
log.info(
"Job definition rejected: id={}, jobName={}, rejectedBy={}",
saved.getId(),
saved.getJobName(),
request.approvedBy());
reviewer);

auditService.logEvent(
new AuditEvent(
AuditEventType.JOB_REJECTED,
saved.getJobName(),
AuditService.currentUserId(),
"Rejected by " + request.approvedBy(),
reviewer,
"Rejected by " + reviewer,
AuditEvent.SUCCESS,
LocalDateTime.now()));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Spring Web Configuration
#---------------------
server.servlet.context-path=/api/batch-service
management.endpoints.web.exposure.include=health,info,env,prometheus
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.group.plugins.include=pluginHealthIndicator
# Validate that health group members exist (catches misconfiguration)
management.endpoint.health.validate-group-membership=true
Expand Down
16 changes: 10 additions & 6 deletions fr-batch-service/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# Spring Web Configuration
#---------------------
server.servlet.context-path=/api/batch-service
management.endpoints.web.exposure.include=health,info,env,prometheus
# Do NOT expose `env` here: it dumps resolved properties (incl. credentials) and
# this base value is inherited by any profile that does not override it.
management.endpoints.web.exposure.include=health,info,prometheus
management.endpoint.health.group.plugins.include=pluginHealthIndicator
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# Jackson Object Mapper Configuration
Expand Down Expand Up @@ -99,14 +101,16 @@ fr-batch-service.jobs.job2.chunk-size=10
fr-batch-service.jobs.job2.retry.max-attempts=3
fr-batch-service.jobs.job2.retry.backoff-millis=2000
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# Security Configuration (HTTP Basic, dev plain-text passwords)
# Note: {noop} prefix documents that passwords are plain text.
# Migrate to {bcrypt} hashes before production deployment.
# Security Configuration (HTTP Basic)
# Passwords have NO defaults here — each active profile MUST supply them:
# local: application-local.properties (admin123 / viewer123, noop)
# docker: application-docker.properties (admin123 / viewer123, noop)
# production: application-production.properties (bcrypt hashes via env vars)
# Usernames default to admin/viewer as a convenience; passwords are intentionally
# absent so the app fails to start rather than running with leaked plain-text defaults.
#---------------------
app.security.admin-user=admin
app.security.admin-password={noop}admin123
app.security.viewer-user=viewer
app.security.viewer-password={noop}viewer123
#>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# Rest clients configs
#---------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,18 @@ void enableDefinition_setsEnabledTrue() throws Exception {
@Test
@Order(3)
void approveDefinition_setsApproved() throws Exception {
// The approvedBy field in the request body is accepted for backward compatibility
// but intentionally ignored — the controller derives the approver from the
// authenticated principal (AuditService.currentUserId()). In the test context
// there is no authenticated user, so the fallback "system" is recorded.
mockMvc
.perform(
put(JOBS_BASE + "/definitions/" + definitionId + "/approve")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"approved_by\":\"test-user\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.approval_status").value("APPROVED"))
.andExpect(jsonPath("$.approved_by").value("test-user"))
.andExpect(jsonPath("$.approved_by").value("system"))
.andExpect(jsonPath("$.approved_at").exists());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ class SecurityConfigPasswordEncoderProfileTest {

// SecurityConfig is annotated with @EnableWebSecurity, which imports the
// HttpSecurity infrastructure — no SecurityAutoConfiguration needed here.
// SecurityProperties has no Java-level password defaults (fail-safe by design), so the
// isolated web slice must supply explicit credentials or UserDetails building would fail.
private static final String[] TEST_CREDENTIALS = {
"app.security.admin-password={noop}test-admin", "app.security.viewer-password={noop}test-viewer"
};

private WebApplicationContextRunner runnerWithProfiles(String... profiles) {
return new WebApplicationContextRunner()
.withUserConfiguration(SecurityConfig.class)
.withPropertyValues(TEST_CREDENTIALS)
.withInitializer(ctx -> ctx.getEnvironment().setActiveProfiles(profiles));
}

Expand Down Expand Up @@ -73,6 +80,7 @@ void defaultProfile_hasSingleNoOpEncoder() {
// No profile active at all — the !production NoOp bean must still be the only one.
new WebApplicationContextRunner()
.withUserConfiguration(SecurityConfig.class)
.withPropertyValues(TEST_CREDENTIALS)
.run(
context -> {
assertThat(context).hasNotFailed();
Expand Down
Loading