Skip to content

[JENKINS-65558] Added Configuration as Code support for email templates#1480

Open
Woeter69 wants to merge 5 commits intojenkinsci:mainfrom
Woeter69:casc_email_templates
Open

[JENKINS-65558] Added Configuration as Code support for email templates#1480
Woeter69 wants to merge 5 commits intojenkinsci:mainfrom
Woeter69:casc_email_templates

Conversation

@Woeter69
Copy link
Copy Markdown

@Woeter69 Woeter69 commented Mar 7, 2026

Resolves #1356 / [JENKINS-65558]

Problem

Custom email templates used by the Email Extension Plugin (Groovy, Jelly, etc.) must be manually placed in the $JENKINS_HOME/email-templates/ directory on the Jenkins master. There was no way to manage these templates programmatically using Jenkins Configuration as Code (JCasC), which complicates automated provisioning and infrastructure-as-code deployments.

Solution

This PR adds support for provisioning email templates directly via JCasC YAML configuration.

Templates defined within the email-ext unclassified configuration are automatically validated and written to the $JENKINS_HOME/email-templates/ directory during Jenkins startup or when CasC configuration is reloaded.

Security Implementation

Strict validation is applied to prevent any malicious file writing:

  • Filename constraints: Rejects names containing path traversal characters (/, \, ..), null bytes, or spaces.
  • Extension whitelist: Enforces that template files must end with .groovy, .jelly, or .template.
  • Path Checking: A canonical path check ensures the final resolved template file strictly remains within the bounds of the email-templates/ directory.

Usage (JCasC)

unclassified:
  email-ext:
    emailTemplates:
      - name: "custom-template.groovy"
        content: |
          def subject = "Build Notification"
          def body = "Build ${PROJECT_NAME} finished with status ${BUILD_STATUS}."
          return body
      - name: "html-alert.jelly"
        content: |
          <j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define">
            <h1>Build Failed</h1>
          </j:jelly>

@Woeter69 Woeter69 requested a review from a team as a code owner March 7, 2026 14:08
@slide
Copy link
Copy Markdown
Member

slide commented Mar 7, 2026

@jenkinsci/core-security-review Can you look this over and tell me if you see any security issues with this? I think it should be ok since admins would be the ones using JCasC to provision Jenkins, but I just want to make sure there is no issue.

@daniel-beck daniel-beck requested a review from Kevin-CB March 8, 2026 15:23
Copy link
Copy Markdown
Member

@daniel-beck daniel-beck left a comment

Choose a reason for hiding this comment

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

Some questionable decisions. I would not be surprised at all to find out that this was vibecoded.

https://github.com/Woeter69/email-ext-plugin/blob/7e820bc4a2157202a7d5f46b01b92592ccacfb7b/src/main/java/hudson/plugins/emailext/ExtendedEmailPublisherDescriptor.java#L942 means that this would allow Overall/Manage users to configure by submitting crafted JSON form submissions. This would be a big security issue.

I would not accept it in its current form, but the general idea is reasonable.

TBH my suggestion would be to move away from file based templates. Also a nice opportunity to move away from the current set of (weird) templates (who's Larry!?).

Comment on lines +844 to +846
// Canonical path check: ensure resolved file is inside the templates directory
String canonicalDir = templatesDir.getCanonicalPath();
String canonicalFile = templateFile.getCanonicalPath();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Doesn't work on Windows before Java 24.

Also, why all this effort here when it's already done in EmailTemplate (or vice versa)?

// Canonical path check: ensure resolved file is inside the templates directory
String canonicalDir = templatesDir.getCanonicalPath();
String canonicalFile = templateFile.getCanonicalPath();
if (!canonicalFile.startsWith(canonicalDir + File.separator)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can be done more nicely with java.nio.Path#startsWith.

Comment on lines +817 to +832
public List<EmailTemplate> getEmailTemplates() {
return emailTemplates;
}

/**
* Sets and provisions email templates from Configuration as Code.
*
* <p>
* Each template is validated for security (safe filename, allowed extension,
* no path traversal) and then written to
* {@code $JENKINS_HOME/email-templates/}.
*
* @param emailTemplates the list of templates to provision
*/
@DataBoundSetter
public void setEmailTemplates(List<EmailTemplate> emailTemplates) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will never delete previously defined email templates, and therefore potentially accumulate a list of obsolete (possibly deemed unsafe/inadvisable) templates.

Comment on lines +46 to +61
String trimmedName = name.trim();

if (trimmedName.isEmpty()) {
throw new IllegalArgumentException("Template name must not be empty");
}

// Reject path traversal and directory separators
if (trimmedName.contains("/") || trimmedName.contains("\\") || trimmedName.contains("..")) {
throw new IllegalArgumentException(
"Template name must not contain path separators or relative path components: " + trimmedName);
}

// Reject null bytes
if (trimmedName.contains("\0")) {
throw new IllegalArgumentException("Template name must not contain null bytes");
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

All of this is unnecessary with the regex (possibly better errors?).

@Woeter69
Copy link
Copy Markdown
Author

Woeter69 commented Mar 9, 2026

Yes, I'll start working on the things mentioned ASAP.

@ArpanC6
Copy link
Copy Markdown

ArpanC6 commented Mar 18, 2026

Hi @Woeter69! After reviewing daniel-beck's feedback, here's a summary of what needs to be addressed:

  1. Security issue (most critical): Remove @DataBoundSetter from setEmailTemplates() or restrict it to ADMINISTER permission check, since currently any Overall/Manage user could exploit this via crafted JSON to write arbitrary files.
  2. Windows path fix: Replace getCanonicalPath() with java.nio.Path#startsWith:
    javaPath resolvedTemplate = templateFile.toPath().toRealPath();
    Path resolvedDir = templatesDir.toPath().toRealPath();
    if (!resolvedTemplate.startsWith(resolvedDir)) { ... }
  3. Template cleanup: In setEmailTemplates(), before writing new templates, collect existing template filenames and delete ones not present in the new list.
  4. Simplify validation: The manual checks in EmailTemplate.java (lines 46-61) are redundant if the regex already covers them — remove the manual checks and rely on the regex.
    Hope this helps with the fixes!

@Woeter69
Copy link
Copy Markdown
Author

i've done some changes, please review my changes and provide me with some feedback, Thanks!

@ArpanC6
Copy link
Copy Markdown

ArpanC6 commented Mar 18, 2026

Hi @Woeter69! Great work addressing the feedback. Here's what I can see has been fixed:

  1. Security fix: configure() method now checks Jenkins.ADMINISTER permission before processing emailTemplates
  2. Windows path fix: getCanonicalPath() replaced with nio.Path#normalize().toAbsolutePath() and startsWith() — correct approach
  3. Template cleanup: Old templates are deleted before writing new ones — accumulation issue resolved
  4. Validation simplified: EmailTemplate.java now relies solely on SAFE_NAME_PATTERN regex — manual checks removed
  5. Tests added: shouldProvisionEmailTemplates() and shouldRejectInvalidTemplateNames() cover the main scenarios
    One remaining concern: The @DataBoundSetter on setEmailTemplates() is still present. daniel-beck's original concern was that this allows form submission bypass — even with the configure() permission check, a crafted JSON POST to the form endpoint could still call the setter directly. Consider removing @DataBoundSetter and handling this only through configure().
    Overall this is significantly improved!

@Woeter69
Copy link
Copy Markdown
Author

@slide i've made some changes after the review, i'm looking forward to some feedback, thank you.

@slide
Copy link
Copy Markdown
Member

slide commented Mar 23, 2026

I'll review soon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[JENKINS-65558] Add CasC support for /email-templates/

4 participants