diff --git a/internal/handler/api_handler.go b/internal/handler/api_handler.go index 8865c52..cec11bf 100644 --- a/internal/handler/api_handler.go +++ b/internal/handler/api_handler.go @@ -7,6 +7,7 @@ import ( "homework-manager/internal/middleware" "homework-manager/internal/service" + "homework-manager/internal/validation" "github.com/gin-gonic/gin" ) @@ -264,6 +265,11 @@ func (h *APIHandler) CreateAssignment(c *gin.Context) { return } + if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + dueDate, err := parseDateString(input.DueDate) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"}) @@ -386,6 +392,11 @@ func (h *APIHandler) UpdateAssignment(c *gin.Context) { return } + if err := validation.ValidateAssignmentInput(input.Title, input.Description, input.Subject, input.Priority); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + title := input.Title if title == "" { title = existing.Title diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go index cfe45b9..e5f865e 100644 --- a/internal/handler/assignment_handler.go +++ b/internal/handler/assignment_handler.go @@ -9,6 +9,7 @@ import ( "homework-manager/internal/middleware" "homework-manager/internal/models" "homework-manager/internal/service" + "homework-manager/internal/validation" "github.com/gin-gonic/gin" ) @@ -126,6 +127,22 @@ func (h *AssignmentHandler) Create(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") + if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil { + role, _ := c.Get(middleware.UserRoleKey) + name, _ := c.Get(middleware.UserNameKey) + RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{ + "title": "課題登録", + "error": err.Error(), + "formTitle": title, + "description": description, + "subject": subject, + "priority": priority, + "isAdmin": role == "admin", + "userName": name, + }) + return + } + reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderAtStr := c.PostForm("reminder_at") var reminderAt *time.Time @@ -298,6 +315,11 @@ func (h *AssignmentHandler) Update(c *gin.Context) { priority := c.PostForm("priority") dueDateStr := c.PostForm("due_date") + if err := validation.ValidateAssignmentInput(title, description, subject, priority); err != nil { + c.Redirect(http.StatusFound, "/assignments") + return + } + reminderEnabled := c.PostForm("reminder_enabled") == "on" reminderAtStr := c.PostForm("reminder_at") var reminderAt *time.Time diff --git a/internal/validation/validation.go b/internal/validation/validation.go new file mode 100644 index 0000000..98d689b --- /dev/null +++ b/internal/validation/validation.go @@ -0,0 +1,173 @@ +package validation + +import ( + "fmt" + "regexp" + "strings" + "unicode" +) + +var MaxLengths = map[string]int{ + "title": 200, + "description": 5000, + "subject": 100, + "priority": 20, +} + +var xssPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)<\s*script`), + regexp.MustCompile(`(?i)]*on\w+\s*=`), + regexp.MustCompile(`(?i)data\s*:\s*text/html`), + regexp.MustCompile(`(?i)<\s*img[^>]*on\w+\s*=`), + regexp.MustCompile(`(?i)expression\s*\(`), + regexp.MustCompile(`(?i)alert\s*\(`), + regexp.MustCompile(`(?i)confirm\s*\(`), + regexp.MustCompile(`(?i)prompt\s*\(`), + regexp.MustCompile(`(?i)document\s*\.\s*cookie`), + regexp.MustCompile(`(?i)document\s*\.\s*location`), + regexp.MustCompile(`(?i)window\s*\.\s*location`), +} + +var sqlInjectionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)'\s*or\s+`), + regexp.MustCompile(`(?i)'\s*and\s+`), + regexp.MustCompile(`(?i)"\s*or\s+`), + regexp.MustCompile(`(?i)"\s*and\s+`), + regexp.MustCompile(`(?i)union\s+(all\s+)?select`), + regexp.MustCompile(`(?i);\s*(drop|delete|update|insert|alter|truncate)\s+`), + regexp.MustCompile(`(?i)--\s*$`), + regexp.MustCompile(`(?i)/\*.*\*/`), + regexp.MustCompile(`(?i)'\s*;\s*`), + regexp.MustCompile(`(?i)exec\s*\(`), + regexp.MustCompile(`(?i)xp_\w+`), + regexp.MustCompile(`(?i)load_file\s*\(`), + regexp.MustCompile(`(?i)into\s+(out|dump)file`), + regexp.MustCompile(`(?i)benchmark\s*\(`), + regexp.MustCompile(`(?i)sleep\s*\(\s*\d`), + regexp.MustCompile(`(?i)waitfor\s+delay`), + regexp.MustCompile(`(?i)1\s*=\s*1`), + regexp.MustCompile(`(?i)'1'\s*=\s*'1`), +} + +var pathTraversalPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\.\.[\\/]`), + regexp.MustCompile(`\.\.%2[fF]`), + regexp.MustCompile(`%2e%2e[\\/]`), + regexp.MustCompile(`\.\./`), + regexp.MustCompile(`\.\.\\`), +} + +var commandInjectionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`^\s*;`), + regexp.MustCompile(`;\s*\w+`), + regexp.MustCompile(`\|\s*\w+`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`&&\s*\w+`), + regexp.MustCompile(`\|\|\s*\w+`), +} + +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +func ValidateAssignmentInput(title, description, subject, priority string) error { + if err := ValidateField("title", title, true); err != nil { + return err + } + if err := ValidateField("description", description, false); err != nil { + return err + } + if err := ValidateField("subject", subject, false); err != nil { + return err + } + if err := ValidateField("priority", priority, false); err != nil { + return err + } + return nil +} + +func ValidateField(fieldName, value string, required bool) error { + if required && strings.TrimSpace(value) == "" { + return &ValidationError{Field: fieldName, Message: "必須項目です"} + } + + if value == "" { + return nil + } + + if maxLen, ok := MaxLengths[fieldName]; ok { + if len(value) > maxLen { + return &ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("最大%d文字までです", maxLen), + } + } + } + + if fieldName != "description" { + for _, r := range value { + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { + return &ValidationError{ + Field: fieldName, + Message: "不正な制御文字が含まれています", + } + } + } + } + + for _, pattern := range xssPatterns { + if pattern.MatchString(value) { + return &ValidationError{ + Field: fieldName, + Message: "潜在的に危険なHTMLタグまたはスクリプトが含まれています", + } + } + } + + for _, pattern := range sqlInjectionPatterns { + if pattern.MatchString(value) { + return &ValidationError{ + Field: fieldName, + Message: "潜在的に危険なSQL構文が含まれています", + } + } + } + + for _, pattern := range pathTraversalPatterns { + if pattern.MatchString(value) { + return &ValidationError{ + Field: fieldName, + Message: "不正なパス文字列が含まれています", + } + } + } + + for _, pattern := range commandInjectionPatterns { + if pattern.MatchString(value) { + return &ValidationError{ + Field: fieldName, + Message: "潜在的に危険なコマンド構文が含まれています", + } + } + } + + return nil +} + +func SanitizeString(s string) string { + s = strings.ReplaceAll(s, "\x00", "") + s = strings.TrimSpace(s) + return s +} diff --git a/web/static/js/app.js b/web/static/js/app.js index cff7f03..69f9eba 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,28 +1,56 @@ -// Homework Manager JavaScript +const XSS = { + escapeHtml: function (str) { + if (str === null || str === undefined) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + }, -document.addEventListener('DOMContentLoaded', function() { - // Auto-dismiss alerts after 5 seconds (exclude alerts inside modals) + setTextSafe: function (element, text) { + if (element) { + element.textContent = text; + } + }, + + sanitizeUrl: function (url) { + if (!url) return ''; + const cleaned = String(url).replace(/[\x00-\x1F\x7F]/g, '').trim(); + try { + const parsed = new URL(cleaned, window.location.origin); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.href; + } + } catch (e) { + if (cleaned.startsWith('/') && !cleaned.startsWith('//')) { + return cleaned; + } + } + return ''; + } +}; + +window.XSS = XSS; + +document.addEventListener('DOMContentLoaded', function () { const alerts = document.querySelectorAll('.alert:not(.alert-danger):not(.modal .alert)'); - alerts.forEach(function(alert) { - setTimeout(function() { + alerts.forEach(function (alert) { + setTimeout(function () { alert.classList.add('fade'); - setTimeout(function() { + setTimeout(function () { alert.remove(); }, 150); }, 5000); }); - // Confirm dialogs for dangerous actions const confirmForms = document.querySelectorAll('form[data-confirm]'); - confirmForms.forEach(function(form) { - form.addEventListener('submit', function(e) { + confirmForms.forEach(function (form) { + form.addEventListener('submit', function (e) { if (!confirm(form.dataset.confirm)) { e.preventDefault(); } }); }); - // Set default datetime to now + 1 day for new assignments const dueDateInput = document.getElementById('due_date'); if (dueDateInput && !dueDateInput.value) { const tomorrow = new Date();