Compare commits

...

3 Commits

Author SHA1 Message Date
Charlie Chiang
7a5ab077a4
Merge c9ab7eb86ff747a0552bef1af4376f7e0405bc38 into 5e5ff3ace496a31b138b0820136a146bfb5de0ef 2025-11-06 13:47:21 +07:00
Bo-Yi Wu
5e5ff3ace4
ci: replace vulnerability scanning workflow with Trivy integration (#4421)
- Remove the vulnerability-scanning job from the gin workflow
- Add a dedicated Trivy security scan workflow with scheduled, push, pull request, and manual triggers
- Improve Trivy scan output by uploading SARIF results to the GitHub Security tab and logging table output

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-11-06 14:15:50 +08:00
Charlie Chiang
c9ab7eb86f feat: add an option to allow trailing slash insensitive matching
Signed-off-by: Charlie Chiang <charlie_c_0129@outlook.com>
2025-07-25 15:42:13 +08:00
4 changed files with 212 additions and 42 deletions

View File

@ -81,19 +81,3 @@ jobs:
uses: codecov/codecov-action@v5
with:
flags: ${{ matrix.os }},go-${{ matrix.go }},${{ matrix.test-tags }}
vulnerability-scanning:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/trivy-action@0.33.1
with:
scan-type: "fs"
ignore-unfixed: true
format: "table"
exit-code: "1"
severity: "CRITICAL,HIGH,MEDIUM"

57
.github/workflows/trivy-scan.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: Trivy Security Scan
on:
push:
branches:
- master
pull_request:
branches:
- master
schedule:
# Run every 3 months (quarterly) on the 1st day at 00:00 UTC
# Months: January (1), April (4), July (7), October (10)
- cron: '0 0 1 1,4,7,10 *'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
security-events: write # Required for uploading SARIF results
jobs:
trivy-scan:
name: Trivy Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Run Trivy vulnerability scanner (source code)
uses: aquasecurity/trivy-action@0.33.1
with:
scan-type: 'fs'
scan-ref: '.'
scanners: 'vuln,secret,misconfig'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM'
ignore-unfixed: true
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: 'trivy-results.sarif'
- name: Run Trivy scanner (table output for logs)
uses: aquasecurity/trivy-action@0.33.1
if: always()
with:
scan-type: 'fs'
scan-ref: '.'
scanners: 'vuln,secret,misconfig'
format: 'table'
severity: 'CRITICAL,HIGH,MEDIUM'
ignore-unfixed: true
exit-code: '1'

80
gin.go
View File

@ -112,6 +112,15 @@ type Engine struct {
// RedirectTrailingSlash is independent of this option.
RedirectFixedPath bool
// TrailingSlashInsensitivity makes the router insensitive to trailing
// slashes. It works like RedirectTrailingSlash, but instead of generating a
// redirection response to the path with or without the trailing slash, it
// will just go to the corresponding handler.
//
// Enabling this option will make RedirectTrailingSlash ineffective since
// no redirection will be performed.
TrailingSlashInsensitivity bool
// HandleMethodNotAllowed if enabled, the router checks if another method is allowed for the
// current route, if the current request can not be routed.
// If this is the case, the request is answered with 'Method Not Allowed'
@ -184,12 +193,13 @@ var _ IRouter = (*Engine)(nil)
// New returns a new blank Engine instance without any middleware attached.
// By default, the configuration is:
// - RedirectTrailingSlash: true
// - RedirectFixedPath: false
// - HandleMethodNotAllowed: false
// - ForwardedByClientIP: true
// - UseRawPath: false
// - UnescapePathValues: true
// - RedirectTrailingSlash: true
// - RedirectFixedPath: false
// - TrailingSlashInsensitivity: false
// - HandleMethodNotAllowed: false
// - ForwardedByClientIP: true
// - UseRawPath: false
// - UnescapePathValues: true
func New(opts ...OptionFunc) *Engine {
debugPrintWARNINGNew()
engine := &Engine{
@ -198,22 +208,23 @@ func New(opts ...OptionFunc) *Engine {
basePath: "/",
root: true,
},
FuncMap: template.FuncMap{},
RedirectTrailingSlash: true,
RedirectFixedPath: false,
HandleMethodNotAllowed: false,
ForwardedByClientIP: true,
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
TrustedPlatform: defaultPlatform,
UseRawPath: false,
RemoveExtraSlash: false,
UnescapePathValues: true,
MaxMultipartMemory: defaultMultipartMemory,
trees: make(methodTrees, 0, 9),
delims: render.Delims{Left: "{{", Right: "}}"},
secureJSONPrefix: "while(1);",
trustedProxies: []string{"0.0.0.0/0", "::/0"},
trustedCIDRs: defaultTrustedCIDRs,
FuncMap: template.FuncMap{},
RedirectTrailingSlash: true,
RedirectFixedPath: false,
TrailingSlashInsensitivity: false,
HandleMethodNotAllowed: false,
ForwardedByClientIP: true,
RemoteIPHeaders: []string{"X-Forwarded-For", "X-Real-IP"},
TrustedPlatform: defaultPlatform,
UseRawPath: false,
RemoveExtraSlash: false,
UnescapePathValues: true,
MaxMultipartMemory: defaultMultipartMemory,
trees: make(methodTrees, 0, 9),
delims: render.Delims{Left: "{{", Right: "}}"},
secureJSONPrefix: "while(1);",
trustedProxies: []string{"0.0.0.0/0", "::/0"},
trustedCIDRs: defaultTrustedCIDRs,
}
engine.engine = engine
engine.pool.New = func() any {
@ -691,6 +702,19 @@ func (engine *Engine) handleHTTPRequest(c *Context) {
return
}
if httpMethod != http.MethodConnect && rPath != "/" {
// TrailingSlashInsensitivity has precedence over RedirectTrailingSlash.
if value.tsr && engine.TrailingSlashInsensitivity {
// Retry with the path with or without the trailing slash.
// It should succeed because tsr is true.
value = root.getValue(addOrRemoveTrailingSlash(rPath), c.params, c.skippedNodes, unescape)
if value.handlers != nil {
c.handlers = value.handlers
c.fullPath = value.fullPath
c.Next()
c.writermem.WriteHeaderNow()
return
}
}
if value.tsr && engine.RedirectTrailingSlash {
redirectTrailingSlash(c)
return
@ -745,6 +769,13 @@ func serveError(c *Context, code int, defaultMessage []byte) {
c.writermem.WriteHeaderNow()
}
func addOrRemoveTrailingSlash(p string) string {
if length := len(p); length > 1 && p[length-1] == '/' {
return p[:length-1]
}
return p + "/"
}
func redirectTrailingSlash(c *Context) {
req := c.Request
p := req.URL.Path
@ -754,10 +785,7 @@ func redirectTrailingSlash(c *Context) {
p = prefix + "/" + req.URL.Path
}
req.URL.Path = p + "/"
if length := len(p); length > 1 && p[length-1] == '/' {
req.URL.Path = p[:length-1]
}
req.URL.Path = addOrRemoveTrailingSlash(p)
redirectRequest(c)
}

View File

@ -246,6 +246,107 @@ func TestRouteRedirectTrailingSlash(t *testing.T) {
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestRouteTrailingSlashInsensitivity(t *testing.T) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = true
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
router.GET("/path2/", func(c *Context) { c.String(http.StatusOK, "path2") })
// Test that trailing slash insensitivity works.
w := PerformRequest(router, http.MethodGet, "/path/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path2/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path2", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path2")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path2", w.Body.String())
// If handlers for `/path` and `/path/` are different, the request should not be redirected.
router.GET("/path3", func(c *Context) { c.String(http.StatusOK, "path3") })
router.GET("/path3/", func(c *Context) { c.String(http.StatusOK, "path3/") })
w = PerformRequest(router, http.MethodGet, "/path3")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path3", w.Body.String())
w = PerformRequest(router, http.MethodGet, "/path3/")
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path3/", w.Body.String())
// Should no longer match.
router.TrailingSlashInsensitivity = false
w = PerformRequest(router, http.MethodGet, "/path2")
assert.Equal(t, http.StatusNotFound, w.Code)
w = PerformRequest(router, http.MethodGet, "/path/")
assert.Equal(t, http.StatusNotFound, w.Code)
}
func BenchmarkRouteTrailingSlashInsensitivity(b *testing.B) {
b.Run("Insensitive", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = true
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Cause an insensitive match. Test if the retry logic is causing
// slowdowns.
w := PerformRequest(router, http.MethodGet, "/path/")
if w.Code != http.StatusOK || w.Body.String() != "path" {
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}
})
b.Run("Exact", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = false
router.TrailingSlashInsensitivity = false
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := PerformRequest(router, http.MethodGet, "/path") // Exact match.
if w.Code != http.StatusOK || w.Body.String() != "path" {
b.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}
})
b.Run("Redirect", func(b *testing.B) {
router := New()
router.RedirectTrailingSlash = true
router.TrailingSlashInsensitivity = false
router.GET("/path", func(c *Context) { c.String(http.StatusOK, "path") })
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w := PerformRequest(router, http.MethodGet, "/path/") // Redirect.
if w.Code != http.StatusMovedPermanently {
b.Fatalf("Expected status %d, got %d", http.StatusMovedPermanently, w.Code)
}
}
})
}
func TestRouteRedirectFixedPath(t *testing.T) {
router := New()
router.RedirectFixedPath = true