diff --git a/routers/web/security_txt.go b/routers/web/security_txt.go new file mode 100644 index 0000000000..f97e8b9c5b --- /dev/null +++ b/routers/web/security_txt.go @@ -0,0 +1,24 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" +) + +const securityTxtContent = `Contact: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/CONTRIBUTING.md +Contact: mailto:security@forgejo.org +Expires: 2025-06-25T00:00:00Z +Policy: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/CONTRIBUTING.md +Preferred-Languages: en +` + +// returns /.well-known/security.txt content +// RFC 9116, https://www.rfc-editor.org/rfc/rfc9116 +// https://securitytxt.org/ +func securityTxt(ctx *context.Context) { + ctx.PlainText(http.StatusOK, securityTxtContent) +} diff --git a/routers/web/security_txt_test.go b/routers/web/security_txt_test.go new file mode 100644 index 0000000000..1edbf445d1 --- /dev/null +++ b/routers/web/security_txt_test.go @@ -0,0 +1,57 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package web + +import ( + "regexp" + "testing" + "time" +) + +func extractLines(message, pattern string) []string { + ptn := regexp.MustCompile(pattern) + return ptn.FindAllString(message, -1) +} + +func TestSecurityTxt(t *testing.T) { + // Contact: is required and value MUST be https:// or mailto: + { + contacts := extractLines(securityTxtContent, `(?m:^Contact: .+$)`) + if contacts == nil { + t.Error("Error: \"Contact: \" field is required") + } + for _, contact := range contacts { + match, err := regexp.MatchString("Contact: (https:)|(mailto:)", contact) + if !match { + t.Error("Error in line ", contact, "\n\"Contact:\" field have incorrect format") + } + if err != nil { + t.Error("Error in line ", contact, err) + } + } + } + // Expires is required + { + expires := extractLines(securityTxtContent, `(?m:^Expires: .+$)`) + if expires == nil { + t.Error("Error: \"Expires: \" field is required") + } + if len(expires) != 1 { + t.Error("Error: \"Expires: \" MUST be single") + } + expRe := regexp.MustCompile(`Expires: (.*)`) + expSlice := expRe.FindStringSubmatch(expires[0]) + if len(expSlice) != 2 { + t.Error("Error: \"Expires: \" have no value") + } + expValue := expSlice[1] + expTime, err := time.Parse(time.RFC3339, expValue) + if err != nil { + t.Error("Error parsing Expires value", expValue, err) + } + if time.Now().AddDate(0, 2, 0).After(expTime) { + t.Error("Error: Expires date time almost in the past", expTime) + } + } +} diff --git a/routers/web/web.go b/routers/web/web.go index e89b9e6479..afd2e77bfb 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -351,6 +351,7 @@ func registerRoutes(m *web.Route) { m.Get("/change-password", func(ctx *context.Context) { ctx.Redirect(setting.AppSubURL + "/user/settings/account") }) + m.Get("/security.txt", securityTxt) }) m.Group("/explore", func() { diff --git a/tests/integration/links_test.go b/tests/integration/links_test.go index 9136f8f915..91655833af 100644 --- a/tests/integration/links_test.go +++ b/tests/integration/links_test.go @@ -38,6 +38,7 @@ func TestLinksNoLogin(t *testing.T) { "/user2/repo1/projects/1", "/assets/img/404.png", "/assets/img/500.png", + "/.well-known/security.txt", } for _, link := range links {