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
36 changes: 36 additions & 0 deletions actions/setup/js/sanitize_content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,42 @@ describe("sanitize_content.cjs", () => {
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Redacted URL:"));
expect(mockCore.debug).toHaveBeenCalledWith(expect.stringContaining("Redacted URL (full):"));
});

it("should support wildcard domain patterns (*.example.com)", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com";
const result = sanitizeContent("Visit https://subdomain.example.com/page and https://another.example.com/path");
expect(result).toBe("Visit https://subdomain.example.com/page and https://another.example.com/path");
});

it("should allow base domain when wildcard pattern is used", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com";
const result = sanitizeContent("Visit https://example.com/page");
expect(result).toBe("Visit https://example.com/page");
});

it("should redact domains not matching wildcard pattern", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com";
const result = sanitizeContent("Visit https://evil.com/malicious");
expect(result).toContain("(redacted)");
});

it("should support mixed wildcard and plain domains", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "github.com,*.githubusercontent.com,api.example.com";
const result = sanitizeContent("Visit https://github.com/repo, https://raw.githubusercontent.com/user/repo/main/file.txt, " + "https://api.example.com/endpoint, and https://subdomain.githubusercontent.com/file");
expect(result).toBe("Visit https://github.com/repo, https://raw.githubusercontent.com/user/repo/main/file.txt, " + "https://api.example.com/endpoint, and https://subdomain.githubusercontent.com/file");
});

it("should redact domains with wildcards that don't match pattern", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "*.github.com";
const result = sanitizeContent("Visit https://github.io/page");
expect(result).toContain("(redacted)");
});

it("should handle multiple levels of subdomains with wildcard", () => {
process.env.GH_AW_ALLOWED_DOMAINS = "*.example.com";
const result = sanitizeContent("Visit https://deep.nested.example.com/page");
expect(result).toBe("Visit https://deep.nested.example.com/page");
});
});

describe("bot trigger neutralization", () => {
Expand Down
15 changes: 15 additions & 0 deletions pkg/workflow/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,21 @@ func (c *Compiler) CompileWorkflowData(workflowData *WorkflowData, markdownPath
return errors.New(formattedErr)
}

// Validate safe-outputs allowed-domains configuration
log.Printf("Validating safe-outputs allowed-domains")
if err := validateSafeOutputsAllowedDomains(workflowData.SafeOutputs); err != nil {
formattedErr := console.FormatError(console.CompilerError{
Position: console.ErrorPosition{
File: markdownPath,
Line: 1,
Column: 1,
},
Type: "error",
Message: err.Error(),
})
return errors.New(formattedErr)
}

// Emit experimental warning for sandbox-runtime feature
if isSRTEnabled(workflowData) {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: sandbox-runtime firewall"))
Expand Down
104 changes: 104 additions & 0 deletions pkg/workflow/safe_outputs_domains_validation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package workflow

import (
"fmt"
"regexp"
"strings"

"github.com/githubnext/gh-aw/pkg/logger"
)

var safeOutputsDomainsValidationLog = logger.New("workflow:safe_outputs_domains_validation")

// domainPattern validates domain patterns including wildcards
// Valid patterns:
// - Plain domains: github.com, api.github.com
// - Wildcard domains: *.github.com
// Invalid patterns:
// - Multiple wildcards: *.*.github.com
// - Wildcard not at start: github.*.com
// - Empty or malformed domains
var domainPattern = regexp.MustCompile(`^(\*\.)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`)
Comment thread
pelikhan marked this conversation as resolved.

// validateSafeOutputsAllowedDomains validates the allowed-domains configuration in safe-outputs
func validateSafeOutputsAllowedDomains(config *SafeOutputsConfig) error {
if config == nil || len(config.AllowedDomains) == 0 {
return nil
}

safeOutputsDomainsValidationLog.Printf("Validating %d allowed domains", len(config.AllowedDomains))

for i, domain := range config.AllowedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("safe-outputs.allowed-domains[%d]: %w", i, err)
}
}

return nil
}

// validateDomainPattern validates a single domain pattern
func validateDomainPattern(domain string) error {
// Check for empty domain
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}

// Check for wildcard-only pattern
if domain == "*" {
return fmt.Errorf("wildcard-only domain '*' is not allowed, use a specific wildcard pattern like '*.example.com'")
}

// Check for wildcard without base domain (must be done before regex)
if domain == "*." {
return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com')", domain)
}

// Check for multiple wildcards
if strings.Count(domain, "*") > 1 {
return fmt.Errorf("domain pattern '%s' contains multiple wildcards, only one wildcard at the start is allowed (e.g., '*.example.com')", domain)
}

// Check for wildcard not at the start
if strings.Contains(domain, "*") && !strings.HasPrefix(domain, "*.") {
return fmt.Errorf("domain pattern '%s' has wildcard in invalid position, wildcard must be at the start followed by a dot (e.g., '*.example.com')", domain)
}

// Additional validation for wildcard patterns
if strings.HasPrefix(domain, "*.") {
baseDomain := domain[2:] // Remove "*."
if baseDomain == "" {
return fmt.Errorf("wildcard pattern '%s' must have a domain after '*.' (e.g., '*.example.com')", domain)
}
// Ensure the base domain doesn't start with a dot
if strings.HasPrefix(baseDomain, ".") {
return fmt.Errorf("wildcard pattern '%s' has invalid format, use '*.example.com' instead of '*.*.example.com'", domain)
}
}

// Validate domain pattern format
if !domainPattern.MatchString(domain) {
// Provide specific error messages for common issues
if strings.HasSuffix(domain, ".") {
return fmt.Errorf("domain pattern '%s' cannot end with a dot", domain)
}
if strings.Contains(domain, "..") {
return fmt.Errorf("domain pattern '%s' cannot contain consecutive dots", domain)
}
if strings.HasPrefix(domain, ".") && !strings.HasPrefix(domain, "*.") {
return fmt.Errorf("domain pattern '%s' cannot start with a dot (except for wildcard patterns like '*.example.com')", domain)
}
// Check for invalid characters
for _, char := range domain {
if (char < 'a' || char > 'z') &&
(char < 'A' || char > 'Z') &&
(char < '0' || char > '9') &&
char != '-' && char != '.' && char != '*' {
return fmt.Errorf("domain pattern '%s' contains invalid character '%c', only alphanumeric, hyphens, dots, and wildcards are allowed", domain, char)
}
}
return fmt.Errorf("domain pattern '%s' is not a valid domain format", domain)
}

return nil
}
Loading