feat(templates): require signatures for javascript templates (#7514)

* feat(templates): require signatures for javascript templates

Unsigned javascript templates should not be
accepted into execution paths when template
signing is enforced.

Apply the signature check to javascript protocol
templates in both direct loading and workflows,
and report those skips separately from code
templates.

Signed-off-by: Dwi Siswanto <[email protected]>

* fix(signer): normalize line endings when signing

The verifier already canonicalizes CRLF to LF
before checking the digest, but the signer still
hashed the raw file content. Templates signed from
a CRLF checkout could fail verification later,
which showed up on Windows after javascript
fixtures started being signed in the integration
harness.

Use the same canonical content for signing and
verification, and add a CRLF sign-and-verify
regression.

Signed-off-by: Dwi Siswanto <[email protected]>

---------

Signed-off-by: Dwi Siswanto <[email protected]>
This commit is contained in:
Dwi Siswanto authored and GitHub committed 2026-07-03 05:50:04 +05:30
1 parent 1427b35cdb
commit fac0b7196d
15 files changed
+260 -41

No files matched your search

+2
View File
@@ -954,8 +954,10 @@ func (r *Runner) displayExecutionInfo(store *loader.Store) {
r.Logger.Warning().Msg("No DAST templates found")
}
stats.ForceDisplayWarning(templates.SkippedUnverifiedCodeTemplateStats)
stats.ForceDisplayWarning(templates.SkippedUnverifiedJavascriptTemplateStats)
} else {
stats.DisplayAsWarning(templates.SkippedUnverifiedCodeTemplateStats)
stats.DisplayAsWarning(templates.SkippedUnverifiedJavascriptTemplateStats)
}
stats.DisplayAsWarning(httpProtocol.SetThreadToCountZero)
+32 -17
View File
@@ -17,8 +17,8 @@ import (
)
var isCodeDisabled = func() bool { return osutils.IsWindows() && os.Getenv("CI") == "true" }
var signedCodeTemplates sync.Once
var signedCodeTemplatesErr error
var signedIntegrationTemplates sync.Once
var signedIntegrationTemplatesErr error
func hasAnyExecutable(names ...string) bool {
for _, name := range names {
@@ -51,23 +51,20 @@ const (
var testcertpath = ""
func ensureSignedCodeTemplates() error {
if isCodeDisabled() {
return nil
}
signedCodeTemplates.Do(func() {
func ensureSignedIntegrationTemplates() error {
signedIntegrationTemplates.Do(func() {
previousWD, err := os.Getwd()
if err != nil {
signedCodeTemplatesErr = err
signedIntegrationTemplatesErr = err
return
}
if err := os.Chdir(suite.fixturesDir); err != nil {
signedCodeTemplatesErr = err
signedIntegrationTemplatesErr = err
return
}
defer func() {
if chdirErr := os.Chdir(previousWD); chdirErr != nil && signedCodeTemplatesErr == nil {
signedCodeTemplatesErr = chdirErr
if chdirErr := os.Chdir(previousWD); chdirErr != nil && signedIntegrationTemplatesErr == nil {
signedIntegrationTemplatesErr = chdirErr
}
}()
@@ -79,13 +76,16 @@ func ensureSignedCodeTemplates() error {
tsigner, err := signer.NewTemplateSignerFromFiles(certPath, keyPath)
if err != nil {
signedCodeTemplatesErr = err
signedIntegrationTemplatesErr = err
return
}
templatesToSign := []string{
"workflow/code-template-1.yaml",
"workflow/code-template-2.yaml",
var templatesToSign []string
if !isCodeDisabled() {
templatesToSign = append(templatesToSign,
"workflow/code-template-1.yaml",
"workflow/code-template-2.yaml",
)
}
for _, v := range codeTestCases {
if v.DisableOn != nil && v.DisableOn() {
@@ -102,14 +102,20 @@ func ensureSignedCodeTemplates() error {
}
templatesToSign = append(templatesToSign, v.Path)
}
for _, v := range jsTestcases {
if v.DisableOn != nil && v.DisableOn() {
continue
}
templatesToSign = append(templatesToSign, v.Path)
}
for _, templatePath := range templatesToSign {
if err := templates.SignTemplate(tsigner, fixturePath(templatePath)); err != nil {
signedCodeTemplatesErr = err
signedIntegrationTemplatesErr = err
return
}
}
})
return signedCodeTemplatesErr
return signedIntegrationTemplatesErr
}
func getEnvValues() []string {
@@ -118,6 +124,15 @@ func getEnvValues() []string {
}
}
func runSignedNucleiTemplateAndGetResults(template, url string, debug bool, extra ...string) ([]string, error) {
if err := ensureSignedIntegrationTemplates(); err != nil {
return nil, err
}
args := []string{"-t", template, "-target", url}
args = append(args, extra...)
return testutils.RunNucleiBareArgsAndGetResults(debug, getEnvValues(), args...)
}
type codeSnippet struct{}
// Execute executes a test case and returns an error if occurred
@@ -19,8 +19,6 @@ import (
"github.com/jcmturner/gokrb5/v8/iana/nametype"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
)
// javascriptASRepRoast exercises templates/ad/asrep-roast.yaml end-to-end
@@ -46,7 +44,7 @@ func (j *javascriptASRepRoast) Execute(filePath string) error {
}
defer kdc.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, kdc.Address(), debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, kdc.Address(), debug)
if err != nil {
return err
}
@@ -94,7 +94,7 @@ func newJavascriptDockerSpec(port string, options *dockertest.RunOptions, readyT
type javascriptNetHttps struct{}
func (j *javascriptNetHttps) Execute(filePath string) error {
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "scanme.sh", debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, "scanme.sh", debug)
if err != nil {
return err
}
@@ -167,7 +167,7 @@ type javascriptMultiPortsSSH struct{}
func (j *javascriptMultiPortsSSH) Execute(filePath string) error {
// use scanme.sh as target to ensure we match on the 2nd default port 22
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "scanme.sh", debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, "scanme.sh", debug)
if err != nil {
return err
}
@@ -177,7 +177,7 @@ func (j *javascriptMultiPortsSSH) Execute(filePath string) error {
type javascriptNoPortArgs struct{}
func (j *javascriptNoPortArgs) Execute(filePath string) error {
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "yo.dawg", debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, "yo.dawg", debug)
if err != nil {
return err
}
@@ -192,7 +192,7 @@ func (j *javascriptWMICommand) Execute(filePath string) error {
// the exclude list and short-circuits before any dial, so the JSON result
// must contain "ok":false plus "network policy" in the error while not
// leaking the password.
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
if err != nil {
return err
}
@@ -204,7 +204,7 @@ type javascriptGoExecRedaction struct{}
func (j *javascriptGoExecRedaction) Execute(filePath string) error {
listener := newGoExecCloseListener()
defer listener.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding)
results, err := runSignedNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding)
if err != nil {
return err
}
@@ -216,7 +216,7 @@ type javascriptGoExecModules struct{}
func (j *javascriptGoExecModules) Execute(filePath string) error {
listener := newGoExecCloseListener()
defer listener.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding)
results, err := runSignedNucleiTemplateAndGetResults(filePath, listener.host, debug, "-V", "RPCEndpoint="+listener.binding)
if err != nil {
return err
}
@@ -275,7 +275,7 @@ func (j *javascriptGoExecSambaNTLM) Execute(filePath string) error {
errS := make([]error, 0, defaultRetry)
for attempt := 1; attempt <= defaultRetry; attempt++ {
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug)
if err == nil {
if countErr := expectResultsCount(results, 1); countErr == nil {
return nil
@@ -383,7 +383,7 @@ func (j *networkMultiStep) Execute(filePath string) error {
})
defer server.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, server.URL, debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, server.URL, debug)
if err != nil {
return err
}
@@ -439,7 +439,7 @@ func runJavascriptDockerCase(filePath string, spec javascriptDockerSpec, expecte
errS := make([]error, 0, defaultRetry)
for attempt := 1; attempt <= defaultRetry; attempt++ {
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, targetAddress, debug)
results, err := runSignedNucleiTemplateAndGetResults(filePath, targetAddress, debug)
if err == nil {
if countErr := expectResultsCount(results, expectedNumbers...); countErr == nil {
return nil
@@ -79,7 +79,7 @@ type javascriptNoAccess struct{}
// Execute executes a test case and returns an error if occurred
func (h *javascriptNoAccess) Execute(filePath string) error {
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, "trust_me_bro.real", debug, "-ms", "-j")
results, err := runSignedNucleiTemplateAndGetResults(filePath, "trust_me_bro.real", debug, "-ms", "-j")
if err != nil {
return err
}
+7 -5
View File
@@ -159,9 +159,9 @@ func executeIntegrationCase(testCase integrationCase) error {
if testCase.DisableOn != nil && testCase.DisableOn() {
return nil
}
if needsSignedCodeTemplates(testCase.Path) {
if err := ensureSignedCodeTemplates(); err != nil {
return fmt.Errorf("failed to sign code templates: %w", err)
if needsSignedIntegrationTemplates(testCase.Path) {
if err := ensureSignedIntegrationTemplates(); err != nil {
return fmt.Errorf("failed to sign integration templates: %w", err)
}
}
@@ -191,8 +191,10 @@ func parallelism() int {
return parallelism
}
func needsSignedCodeTemplates(path string) bool {
return strings.HasPrefix(path, "protocols/code/") || strings.Contains(path, "workflow/code-")
func needsSignedIntegrationTemplates(path string) bool {
return strings.HasPrefix(path, "protocols/code/") ||
strings.Contains(path, "workflow/code-") ||
strings.HasPrefix(path, "protocols/javascript/")
}
func testNameForPath(path string) string {
+10
View File
@@ -902,6 +902,16 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
return
}
// javascript-protocol templates expose Go-backed modules through
// the JS runtime, so unsigned ones are rejected before execution.
if parsed.IsUnsignedJavascriptTemplate() {
stats.Increment(templates.SkippedUnverifiedJavascriptTemplateStats)
if config.DefaultConfig.LogAllEvents {
store.logger.Warning().Msgf("Unverified javascript template at %q", templatePath)
}
return
}
if missingCaps := parsed.MissingLoadCapabilities(caps); len(missingCaps) > 0 {
store.noteMissingCapabilities(templatePath, missingCaps)
return
+116
View File
@@ -160,6 +160,122 @@ code:
require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
}
func loadSingleTemplateForTest(t *testing.T, templatePath, executionID string) []*templates.Template {
t.Helper()
options := testutils.DefaultOptions.Copy()
options.Logger = &gologger.Logger{}
options.ExecutionId = executionID
options.DisableUnsignedTemplates = false
options.TemplateLoadingConcurrency = 1
testutils.Init(options)
t.Cleanup(func() {
testutils.Cleanup(options)
})
catalog := disk.NewCatalog("")
executerOpts := testutils.NewMockExecuterOptions(options, nil)
executerOpts.Catalog = catalog
executerOpts.Parser = templates.NewParser()
executerOpts.Logger = options.Logger
workflowLoader, err := workflow.NewLoader(executerOpts)
require.NoError(t, err)
executerOpts.WorkflowLoader = workflowLoader
store, err := New(NewConfig(options, catalog, executerOpts))
require.NoError(t, err)
loaded, err := store.LoadTemplates([]string{templatePath})
require.NoError(t, err)
return loaded
}
func TestLoadTemplatesRecordsUnsignedJavascriptTemplateOnlyAsJavascriptSkip(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "unsigned-javascript.yaml")
err := os.WriteFile(templatePath, []byte(`id: unsigned-javascript-template
info:
name: Unsigned Javascript Template
author: pdteam
severity: info
javascript:
- code: |
Export("unsigned-javascript-template")
`), 0o600)
require.NoError(t, err)
initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)
initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats)
loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-javascript-template")
require.Empty(t, loaded)
require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
}
func TestLoadTemplatesTreatsMixedTemplateWithJavascriptAsJavascriptSensitive(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "mixed-javascript.yaml")
err := os.WriteFile(templatePath, []byte(`id: mixed-javascript-template
info:
name: Mixed Javascript Template
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}"
matchers:
- type: word
words:
- mixed-javascript-template
javascript:
- code: |
Export("mixed-javascript-template")
`), 0o600)
require.NoError(t, err)
initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)
loaded := loadSingleTemplateForTest(t, templatePath, "loader-mixed-javascript-template")
require.Empty(t, loaded)
require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
}
func TestLoadTemplatesAllowsUnsignedFlowTemplateWithoutJavascriptProtocol(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "flow-only.yaml")
err := os.WriteFile(templatePath, []byte(`id: unsigned-flow-template
info:
name: Unsigned Flow Template
author: pdteam
severity: info
flow: http(1)
http:
- method: GET
path:
- "{{BaseURL}}"
matchers:
- type: word
words:
- unsigned-flow-template
`), 0o600)
require.NoError(t, err)
initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)
loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-flow-template")
require.Len(t, loaded, 1)
require.Equal(t, "unsigned-flow-template", loaded[0].ID)
require.Equal(t, initialUnverifiedJavascript, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
}
func TestLoadTemplatesDoesNotRequireGlobalMatchersFlagToLoadTemplate(t *testing.T) {
templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml")
err := os.WriteFile(templatePath, []byte(`id: global-matchers-template
+49
View File
@@ -284,6 +284,55 @@ workflows:
require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
}
func Test_ParseWorkflowRecordsUnsignedJavascriptSubtemplateOnlyAsJavascriptSkip(t *testing.T) {
setup()
previousDisableUnsigned := executerOpts.Options.DisableUnsignedTemplates
defer func() {
executerOpts.Options.DisableUnsignedTemplates = previousDisableUnsigned
}()
executerOpts.Options.DisableUnsignedTemplates = false
dir := t.TempDir()
javascriptTemplatePath := filepath.Join(dir, "unsigned-javascript.yaml")
err := os.WriteFile(javascriptTemplatePath, []byte(`id: workflow-unsigned-javascript
info:
name: Workflow Unsigned Javascript
author: pdteam
severity: info
javascript:
- code: |
Export("workflow-unsigned-javascript")
`), 0o600)
require.NoError(t, err)
workflowPath := filepath.Join(dir, "workflow.yaml")
err = os.WriteFile(workflowPath, []byte(fmt.Sprintf(`id: workflow-unsigned-javascript-gate
info:
name: Workflow Unsigned Javascript Gate
author: pdteam
severity: info
workflows:
- template: %q
`, javascriptTemplatePath)), 0o600)
require.NoError(t, err)
initialUnverifiedJavascript := stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats)
initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats)
got, err := templates.Parse(workflowPath, nil, executerOpts)
require.NoError(t, err)
require.NotNil(t, got.CompiledWorkflow)
require.Len(t, got.CompiledWorkflow.Workflows, 1)
require.Empty(t, got.CompiledWorkflow.Workflows[0].Executers)
require.Equal(t, initialUnverifiedJavascript+1, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
require.Equal(t, initialUnverified, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
}
func Test_WrongTemplate(t *testing.T) {
setup()
+4 -3
View File
@@ -5,9 +5,10 @@ const (
TemplateSyntaxErrorStats = "template-syntax-errors"
TemplateSyntaxWarningStats = "template-syntax-warnings"
SkippedRequestSignatureTemplateStats = "skipped-request-signature-templates"
SkippedUnverifiedCodeTemplateStats = "skipped-unverified-code-templates"
SkippedUnverifiedTemplateStats = "skipped-unverified-templates"
SkippedRequestSignatureTemplateStats = "skipped-request-signature-templates"
SkippedUnverifiedCodeTemplateStats = "skipped-unverified-code-templates"
SkippedUnverifiedJavascriptTemplateStats = "skipped-unverified-javascript-templates"
SkippedUnverifiedTemplateStats = "skipped-unverified-templates"
ExcludedCodeTemplateStats = "excluded-code-templates"
ExcludedDASTTemplateStats = "excluded-dast-templates"
+6 -3
View File
@@ -73,6 +73,7 @@ func (t *TemplateSigner) GetUserFragment() string {
// Sign signs the given template with the template signer and returns the signature
func (t *TemplateSigner) Sign(data []byte, tmpl SignableTemplate) (string, error) {
existingSignature, content := ExtractSignatureAndContent(data)
content = normalizeTemplateContentForSignature(content)
// while re-signing template check if it has a code protocol
// if it does then verify that it is signed by current signer
@@ -146,9 +147,7 @@ func (t *TemplateSigner) Verify(data []byte, tmpl SignableTemplate) (bool, error
return false, err
}
// normalize content by removing \r\n everywhere since this only done for verification
// it does not affect the actual template
content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
content = normalizeTemplateContentForSignature(content)
buff := bytes.NewBuffer(content)
// if file has any imports process them
@@ -164,6 +163,10 @@ func (t *TemplateSigner) Verify(data []byte, tmpl SignableTemplate) (bool, error
return t.verify(buff.Bytes(), digest)
}
func normalizeTemplateContentForSignature(content []byte) []byte {
return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
}
// Verify verifies the given data with the template signer
// Note: this should not be used for verifying templates as file references
// in templates are not processed
+6
View File
@@ -48,6 +48,12 @@ func TestTemplateSignerSignAndVerify(t *testing.T) {
tmpl: &mockSignableTemplate{},
wantVerified: true,
},
{
name: "Template with CRLF line endings",
data: []byte("id: test-template\r\ninfo:\r\n name: Test Template"),
tmpl: &mockSignableTemplate{},
wantVerified: true,
},
{
name: "Template with imports",
data: []byte("id: test-template\ninfo:\n name: Test Template"),
+4
View File
@@ -24,6 +24,10 @@ var templateStatEntries = []templateStatEntry{
name: SkippedUnverifiedCodeTemplateStats,
description: "Found %d unsigned or tampered code template (carefully examine before using it & use -sign flag to sign them)",
},
{
name: SkippedUnverifiedJavascriptTemplateStats,
description: "Found %d unsigned or tampered javascript template (carefully examine before using it & use -sign flag to sign them)",
},
{
name: ExcludedHeadlessTemplateStats,
description: "Excluded %d headless template[s] (disabled as default), use -headless option to run headless templates.",
+6
View File
@@ -87,6 +87,12 @@ func (t *Template) HasJavascriptRequest(n ...int) bool {
return HasRequest(t.RequestsJavascript, n...)
}
// IsUnsignedJavascriptTemplate returns true if the template has a Javascript
// protocol request but no verified template signature.
func (t *Template) IsUnsignedJavascriptTemplate() bool {
return t.HasJavascriptRequest() && !t.Verified
}
// HasQueueRequests returns true if the template has queued requests.
//
// Queued requests contain all template requests in order (both protocol &
+7
View File
@@ -102,6 +102,13 @@ func parseWorkflowTemplate(workflow *workflows.WorkflowTemplate, preprocessor Pr
continue
}
if template.IsUnsignedJavascriptTemplate() {
// unverified javascript templates are not allowed in workflows
stats.Increment(SkippedUnverifiedJavascriptTemplateStats)
gologger.Warning().Msgf("Skipping unverified javascript template(s) from workflow: %v\n", path)
continue
}
if missingCaps := template.MissingLoadCapabilities(caps); len(missingCaps) > 0 {
for _, capability := range missingCaps {
stats.Increment(capability.Stat())