Skip to content

Commit 7b5d623

Browse files
mfenniakMathieu Fenniak
authored andcommitted
fix: introduce lint-single-response to prevent control flow continuing past a ctx.Error(...)-style method (#13087)
This PR adds a new linter to the codebase and addresses all the problems that it identified (including a small number of false positives). The lint-single-response Go analyzer attempts to prevent a common problem in Forgejo where it is possible for a web handler to provide a response to a request, and then continue code execution unintentionally. For example: ```go err := json.Unmarshal(data, &claims) if err != nil { ctx.Error(http.StatusInternalServerError, "Error in unmarshal", err) // Oops, I forgot to `return` here... } // ... more work occurs ... ctx.JSON(http.StatusOK, resp) ``` In order to detect these cases, lint-single-response contains a list of functions that deliver a web response. When any of those functions are used within a function, the control flow must not perform any work after the function is invoked -- it can only return and exit the function. ### Tests for Go changes - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server. - I ran... - [x] `make pr-go` before pushing ### Documentation - [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change. - Documentation on the new linter is included inline, in `build/lint-single-response/README.md`. - [ ] I did not document these changes and I do not expect someone else to do it. ### Release notes - [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change. - [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/13087 Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
1 parent f01e652 commit 7b5d623

50 files changed

Lines changed: 781 additions & 61 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ linters:
165165
- linters:
166166
- forbidigo
167167
path: cmd
168+
- linters:
169+
- forbidigo
170+
path: build/lint-single-response
168171
- linters:
169172
- dupl
170173
text: (?i)webhook

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ lint-frontend: lint-js tsc lint-css
443443
lint-frontend-fix: lint-js-fix lint-css-fix
444444

445445
.PHONY: lint-backend
446-
lint-backend: lint-go lint-go-vet lint-editorconfig lint-renovate lint-locale lint-locale-usage lint-disposable-emails
446+
lint-backend: lint-go lint-go-vet lint-editorconfig lint-renovate lint-locale lint-locale-usage lint-disposable-emails lint-single-response
447447

448448
.PHONY: lint-backend-fix
449449
lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig lint-disposable-emails-fix
@@ -530,6 +530,10 @@ lint-disposable-emails:
530530
lint-disposable-emails-fix:
531531
$(GO) run build/generate-disposable-email.go -r $(DISPOSABLE_EMAILS_SHA)
532532

533+
.PHONY: lint-single-response
534+
lint-single-response:
535+
$(GO) run ./build/lint-single-response/cmd ./...
536+
533537
.PHONY: security-check
534538
security-check:
535539
$(GO) run $(GOVULNCHECK_PACKAGE) -show color ./...
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# lint-single-response
2+
3+
The lint-single-response Go analyzer attempts to prevent a common problem in Forgejo where it is possible for a web handler to provide a response to a request, and then continue code execution unintentionally. For example:
4+
5+
```go
6+
err := json.Unmarshal(data, &claims)
7+
if err != nil {
8+
ctx.Error(http.StatusInternalServerError, "Error in unmarshal", err)
9+
// Oops, I forgot to `return` here...
10+
}
11+
// ... more work occurs ...
12+
ctx.JSON(http.StatusOK, resp)
13+
```
14+
15+
In order to detect these cases, lint-single-response contains a list of functions that deliver a web response. When any of those functions are used within a function, the control flow must not perform any work after the function is invoked -- it can only return and exit the function.
16+
17+
Methods named `Test...` are omitted from analysis, as this naming scheme suggests a test case where an error would have no user impact, and such methods sometimes invoke web response methods in unusual but safe patterns.
18+
19+
## Limitations
20+
21+
lint-single-response only works within the control-flow of a single function. If a web handler calls another function that invokes `ctx.Error(...)`, then there is no guarantee that the web handler doesn't go on to do more work. This could be addressed in the future but would require a multi-pass analysis -- all functions that invoke web responses would need to be identified, then all functions that invoke those functions would need to be identified, recursively, until no new functions are identified. And then lint-single-response's current behaviour would need to be implemented against that entire set of functions.
22+
23+
## Usage
24+
25+
Direct invocation:
26+
27+
```
28+
go run ./build/lint-single-response/cmd ./...
29+
```
30+
31+
It is also integrated into Forgejo's `Makefile`, and can be run directly as the target `make lint-single-response`, or as part of `make lint-backend` or `make pr-go`.
32+
33+
## Testing
34+
35+
lint-single-response contains internal tests to verify that it works correctly. These tests are included in `make test-backend`, but, Go tends to think that they're cached even if data in `testdata` is changed. For development and testing of lint-single-response, it is recommended to run the tests with `-count 1` to avoid caching:
36+
37+
```
38+
GOTESTFLAGS="-count 1" GO_TEST_PACKAGES=forgejo.org/build/lint-single-response make test-backend
39+
```
40+
41+
Testing is done with the [`analysistest` package](https://pkg.go.dev/golang.org/x/tools@v0.46.0/go/analysis/analysistest#Run). In short, comments `// want ...` indicate that a lint diagnostic must be produced on that line for the test to pass.
42+
43+
An empty implementation of `context.Base`, `context.Context`, and `context.APIContext` are included in the test package so that the exact method signatures being used in Forgejo can be covered in the tests.
44+
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package main
5+
6+
import (
7+
singleresponse "forgejo.org/build/lint-single-response"
8+
9+
"golang.org/x/tools/go/analysis/singlechecker"
10+
)
11+
12+
func main() {
13+
singlechecker.Main(singleresponse.Analyzer)
14+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package singleresponse
5+
6+
import (
7+
"fmt"
8+
"go/ast"
9+
"go/types"
10+
"strings"
11+
12+
"golang.org/x/tools/go/analysis"
13+
"golang.org/x/tools/go/analysis/passes/ctrlflow"
14+
"golang.org/x/tools/go/analysis/passes/inspect"
15+
"golang.org/x/tools/go/ast/inspector"
16+
"golang.org/x/tools/go/cfg"
17+
)
18+
19+
var Analyzer = &analysis.Analyzer{
20+
Name: "singleresponse",
21+
Doc: "checks that Forgejo web response methods are only invoked once in a control flow",
22+
Requires: []*analysis.Analyzer{inspect.Analyzer, ctrlflow.Analyzer},
23+
Run: run,
24+
}
25+
26+
func run(pass *analysis.Pass) (any, error) {
27+
insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
28+
cfgs := pass.ResultOf[ctrlflow.Analyzer].(*ctrlflow.CFGs)
29+
30+
webFuncs := map[string]map[string]any{
31+
"*forgejo.org/services/context.APIContext": {
32+
"Error": true,
33+
"InternalServerError": true,
34+
"NotFound": true,
35+
"NotFoundOrServerError": true,
36+
"ServerError": true,
37+
},
38+
"*forgejo.org/services/context.Base": {
39+
"Error": true,
40+
"JSON": true,
41+
"JSONWithContentType": true,
42+
"PlainText": true,
43+
"PlainTextBytes": true,
44+
"Redirect": true,
45+
"ServeContent": true,
46+
},
47+
"*forgejo.org/services/context.Context": {
48+
"HTML": true,
49+
"JSONError": true,
50+
"JSONOK": true,
51+
"JSONRedirect": true,
52+
"JSONTemplate": true,
53+
"NotFound": true,
54+
"NotFoundOrServerError": true,
55+
"RedirectToFirst": true,
56+
"RenderWithErr": true,
57+
"ServerError": true,
58+
},
59+
// Future: RedirectToUser does not accept a ctx LHS, but rather a first parameter -- needs different
60+
// implementation of detection, or, refactoring: "RedirectToUser": true,
61+
}
62+
63+
insp.Nodes([]ast.Node{
64+
(*ast.FuncDecl)(nil),
65+
(*ast.FuncLit)(nil),
66+
}, func(n ast.Node, push bool) bool {
67+
switch fn := n.(type) {
68+
case *ast.FuncDecl:
69+
// Skip test methods which are assumed to know what they're doing.
70+
if strings.HasPrefix(fn.Name.Name, "Test") {
71+
return false
72+
}
73+
cfg := cfgs.FuncDecl(fn)
74+
if cfg == nil {
75+
return true
76+
}
77+
inspectFunction(cfg, pass, webFuncs)
78+
case *ast.FuncLit:
79+
cfg := cfgs.FuncLit(fn)
80+
if cfg == nil {
81+
return true
82+
}
83+
inspectFunction(cfg, pass, webFuncs)
84+
}
85+
return false
86+
})
87+
88+
return nil, nil //nolint:nilnil
89+
}
90+
91+
func inspectFunction(cfg *cfg.CFG, pass *analysis.Pass, webFuncs map[string]map[string]any) {
92+
for _, block := range cfg.Blocks {
93+
for nodeIdx, node := range block.Nodes {
94+
ast.Inspect(node, func(n ast.Node) bool {
95+
// Don't recurse inside of a function literal inside of a function declaration, as this isn't
96+
// related to the control flow that we're currently iterating through.
97+
_, isFuncLit := n.(*ast.FuncLit)
98+
if isFuncLit {
99+
return false
100+
}
101+
102+
call, isCall := n.(*ast.CallExpr)
103+
if !isCall {
104+
return true
105+
}
106+
107+
// SelectorExpr: "an expression followed by a selector", like "ctx.Error". All the functions
108+
// we're interested in match this pattern.
109+
selector, isSelector := call.Fun.(*ast.SelectorExpr)
110+
if !isSelector {
111+
return false
112+
}
113+
114+
// We almost get the right information easily from the selector by using
115+
// pass.TypesInfo.Uses[selector.X] -- but that will be the type of the variable that we're
116+
// invoking a method on, and not the type of the method receiver. eg. on `ctx
117+
// *context.Context`, `ctx.ServerError(...)` will always be `*context.Context`, even if
118+
// `ServerError` is actually implemented on `*context.Base`.
119+
//
120+
// We need to dig a little deeper here to get the function type, then its signature, and then
121+
// it's receiver type, and we'll really have the method that will be invoked rather than just
122+
// the variable that it is called upon.
123+
selection, hasSelection := pass.TypesInfo.Selections[selector]
124+
if !hasSelection {
125+
return false
126+
}
127+
objFn, ok := selection.Obj().(*types.Func)
128+
if !ok {
129+
return false
130+
}
131+
fnSig, ok := objFn.Type().(*types.Signature)
132+
if !ok {
133+
return false
134+
}
135+
callType := fnSig.Recv().Type().String()
136+
137+
typeMap, inTypeMap := webFuncs[callType]
138+
if inTypeMap {
139+
callName := selector.Sel.Name
140+
_, inFuncMap := typeMap[callName]
141+
if inFuncMap {
142+
// OK... we've found a call to a terminating function at
143+
// cfg.Blocks[blockIdx].Nodes[nodeIdx].
144+
trace := false
145+
// For code-time debugging/analysis, set trace=true when digging into why something isn't
146+
// working:
147+
// if callName == "InternalServerError" {
148+
// trace = true
149+
// }
150+
sketchy := inspectCallSite(block, nodeIdx, trace)
151+
if sketchy != nil {
152+
pass.Reportf(node.Pos(), "Invocation of %s / %s, and control flow continues afterwards.", callType, callName)
153+
}
154+
}
155+
}
156+
157+
return false
158+
})
159+
}
160+
}
161+
}
162+
163+
type sketchyCall struct{}
164+
165+
func inspectCallSite(callingBlock *cfg.Block, callingNodeIndex int, trace bool) *sketchyCall {
166+
// Inspect the remainder of the block passed in, after callingNodeIndex, for "bad" statements
167+
if trace {
168+
println("remainder of block...")
169+
}
170+
for _, nextStmt := range callingBlock.Nodes[callingNodeIndex+1:] {
171+
if trace {
172+
println(fmt.Sprintf("\tnextStmt = %#v", nextStmt))
173+
}
174+
// Only `return` is permitted after one of the web return functions; maybe this needs to expand in the future
175+
// but haven't identified any cases in Forgejo yet.
176+
_, stmtOk := nextStmt.(*ast.ReturnStmt)
177+
if !stmtOk {
178+
if trace {
179+
println(fmt.Sprintf("\tfound sketchy statement = %#v", nextStmt))
180+
}
181+
// Future: add information about what was following the call, so that the diagnostic can be more specific
182+
// about the problematic next statement identified... but so far it seems pretty easy to analyze and fix.
183+
return &sketchyCall{}
184+
}
185+
}
186+
if trace {
187+
println("nothing found in remainder of block")
188+
println(fmt.Sprintf("%d Succs blocks will be investigated", len(callingBlock.Succs)))
189+
}
190+
191+
// Now, assuming that there was nothing problematic found in the remainder of the block, use the control-flow graph
192+
// to identify where code execution would continue and see if there's anything inappropriate in it.
193+
//
194+
// https://pkg.go.dev/golang.org/x/tools@v0.46.0/go/cfg#Block -> A block may have 0-2 successors: zero for a return
195+
// block or a block that calls a function such as panic that never returns; one for a normal (jump) block; and two
196+
// for a conditional (if) block.
197+
//
198+
// It's possible for the next block to have either no nodes, or, no nodes that continue to do work and trigger
199+
// detection... but then to proceed into *another* block that does. So this investigation has to be done
200+
// recursively. Control-flow graph should prevent us from needing to stop this recursive detection; we'll hit a
201+
// return statement or end of function and that's the end of the CFG, and that's also the time we'd want to stop
202+
// looking, so no additional exit logic should be needed.
203+
for i, succ := range callingBlock.Succs {
204+
if trace {
205+
println(fmt.Sprintf("Succs[%d], block index %d, recursing:", i, succ.Index))
206+
}
207+
// `-1` is used to start at index 0 in the nodes.
208+
sketchy := inspectCallSite(succ, -1, trace)
209+
if trace {
210+
println(fmt.Sprintf("Succs[%d], block index %d, had sketchy = %#v", i, succ.Index, sketchy))
211+
}
212+
if sketchy != nil {
213+
return sketchy
214+
}
215+
}
216+
217+
return nil
218+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package singleresponse
5+
6+
import (
7+
"testing"
8+
9+
"golang.org/x/tools/go/analysis/analysistest"
10+
)
11+
12+
func TestSingleResponse(t *testing.T) {
13+
analysistest.Run(t, analysistest.TestData(), Analyzer, "a")
14+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2026 The Forgejo Authors. All rights reserved.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package a
5+
6+
import (
7+
"errors"
8+
9+
"forgejo.org/services/context"
10+
)
11+
12+
func work() {}
13+
14+
func directApiCallFine(ctx *context.APIContext) {
15+
ctx.Error(500, "title", nil)
16+
}
17+
18+
// Directly call APIContext functions, then "do work", triggering a linting error:
19+
20+
func directApiCallError(ctx *context.APIContext) {
21+
ctx.Error(500, "title", nil) // want "Invocation of (.*) / Error, and control flow continues afterwards."
22+
work()
23+
}
24+
25+
func directApiCallInternalServerError(ctx *context.APIContext) {
26+
ctx.InternalServerError(errors.New("something")) // want "Invocation of (.*) / InternalServerError, and control flow continues afterwards."
27+
work()
28+
}
29+
30+
func directApiCallNotFound(ctx *context.APIContext) {
31+
ctx.NotFound("title") // want "Invocation of (.*) / NotFound, and control flow continues afterwards."
32+
work()
33+
}
34+
35+
func directApiCallNotFoundOrServerError(ctx *context.APIContext) {
36+
ctx.NotFoundOrServerError("logMsg", func(err error) bool { return false }, errors.New("something")) // want "Invocation of (.*) / NotFoundOrServerError, and control flow continues afterwards."
37+
work()
38+
}
39+
40+
func directApiCallServerError(ctx *context.APIContext) {
41+
ctx.ServerError("something", errors.New("something")) // want "Invocation of (.*) / ServerError, and control flow continues afterwards."
42+
work()
43+
}
44+
45+
// Call methods on ctx that will go to the `*Base` implementation:
46+
47+
func indirectApiCallJSON(ctx *context.APIContext) {
48+
ctx.JSON(200, "something") // want "Invocation of (.*).Base / JSON, and control flow continues afterwards."
49+
work()
50+
}
51+
52+
func indirectApiCallPlainText(ctx *context.APIContext) {
53+
ctx.PlainText(200, "something") // want "Invocation of (.*).Base / PlainText, and control flow continues afterwards."
54+
work()
55+
}
56+
57+
func indirectApiCallPlainTextBytes(ctx *context.APIContext) {
58+
ctx.PlainTextBytes(200, []byte{}) // want "Invocation of (.*).Base / PlainTextBytes, and control flow continues afterwards."
59+
work()
60+
}
61+
62+
func indirectApiCallRedirect(ctx *context.APIContext) {
63+
ctx.Redirect("/somewhere") // want "Invocation of (.*).Base / Redirect, and control flow continues afterwards."
64+
work()
65+
}
66+
67+
func indirectApiCallServeContent(ctx *context.APIContext) {
68+
ctx.ServeContent(nil, nil) // want "Invocation of (.*).Base / ServeContent, and control flow continues afterwards."
69+
work()
70+
}

0 commit comments

Comments
 (0)