9 minutes
Programmatic handling of CORS-configuration errors with jub0bs/cors
TL;DR ¶
- jub0bs/cors v0.5.0 now lets you handle CORS-configuration errors programmatically.
- This feature should be of interest to you if you’re a multi-tenant service provider and you let your tenants configure CORS for their instances.
jub0bs/cors’s commitment to configuration validation ¶
One long-standing and distinguishing feature of jub0bs/cors is extensive configuration validation, motivated by my desire to rule out dysfunctional CORS middleware and to discourage the instantiation of insecure CORS middleware. When your CORS configuration contains mistakes, the library indeed detects them and reports them as a “multi-error”. For instance, you may attempt to (incorrectly) configure a CORS middleware as shown in the programme below:
package main
import (
"fmt"
"io"
"net/http"
"os"
"github.com/jub0bs/cors"
)
func main() {
_, err := cors.NewMiddleware(cors.Config{
Origins: []string{"*"},
Credentialed: true,
Methods: []string{"POS T"},
ResponseHeaders: []string{"Set-Cookie"},
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Fortunately for you, the library refuses to instantiate such a dysfunctional and insecure CORS middleware and emits the following error message:
cors: invalid method "POS T"
cors: forbidden response-header name "Set-Cookie"
cors: for security reasons, you cannot both allow all origins and enable credentialed access
The need for programmatic handling of CORS-configuration errors ¶
Such an error message as the one shown above is sufficient for most importers to correct their CORS configuration and move on. However, there are (arguably rare) cases where the programme and the CORS configuration are authored by different entities, and such cases call for something more powerful than an undistinguished string error message.
Picture, for example, a multi-tenant service provider that enables, perhaps via some Web interface and/or some command-line interface, each of its tenants to configure CORS for their instance of the service. When a tenant misconfigures CORS, the service provider somehow needs to explain the reasons for misconfiguration to the tenant, so that the tenant can accordingly take remedial action. Simply forwarding the error messages produced by jub0bs/cors to the tenant is tempting and expedient, but it’s far from ideal: the level of detail, wording, format, and/or natural language (English) of those messages may indeed be inadequate for the tenant. The service provider may instead wish to produce more palatable messages, such as this one:
[
"\"POS T\" is not a valid HTTP method. Did you mean \"POST\"?",
"\"Set-Cookie\" is not a response header that can be exposed.",
"You cannot allow access from all origins with cookies."
]
To do so, the service provider would need to
inspect the errors emitted by jub0bs/cors,
make sense of them, and
extract contextual information (such as "POS T"
and "Set-Cookie"
) from them.
Unfortunately, up until recently,
jub0bs/cors used to be regrettably limited in that regard.
The service provider in my example would have had no other choice
but to parse the messages of those errors
(i.e. the output of the latter’s Error
method),
a practice that is widely discouraged, and for good reasons.
Jon Amsterdam, a member of the Go team, has this nice saying:
Errors have two audiences: people and programs.
This pithy statement reflects the dual nature of errors. Error messages are to be consumed by people, not by programmes. Few programmes should parse error messages, because such parsing is extremely brittle: a single change to the format of an error message may break the parsing logic. Moreover, convention in the Go community dictates that error messages are not part of a package’s API and that package authors may freely change them from one version to the next (be it a major, minor, or patch version). Therefore, package authors who wish to allow programmes to extract information from their package’s error values should provide a programmatic way of doing so.
To properly support programmatic handling of CORS-configuration errors, I realised that I needed to introduce and export concrete error types corresponding to the various reasons for CORS misconfiguration. Resolved to implement this feature at some stage, I filed issue 9 on GitHub and added it to my backlog.
“One chance to get it right” ¶
If you’re like me, broadening the exported surface area of your package should fill you with dread. As Josh Bloch puts it in his legendary API-design talk,
You have one chance to get it right.
Adding symbols to a package is deceptively easy, but modifying symbols without breaking existing clients may prove difficult, and removing symbols altogether is painful because it requires a major-version bump. Make one design mistake, and you may be stuck with it until the release of the next major version of your library. Accordingly, the decision to export more stuff should be deliberate and carefully considered; endeavour to keep your options open and avoid making promises you may want to break in the future.
Because jub0bs/cors still hasn’t seen a v1 release, any breaking change is technically fair game; however, I avoid breaking changes like the plague, for fear of user churn. These considerations may explain why I ended up addressing issue 9 only belatedly.
Defining some errors out of existence ¶
It quickly dawned on me that, without any other changes to the library, the required set of concrete error types would be too large to be manageable. As often, I found the answer in John Ousterhout’s writing:
The best way to eliminate exception handling complexity is to define your APIs so that there are no [or fewer] exceptions to handle: define errors out of existence.
(A philosophy of software design, John Ousterhout, 2nd. edition, section 10.3)
With this design principle in mind, I modified the library so as to gracefully tolerate benign configuration infelicities that were hitherto bubbled up as errors to callers. An immediate side benefit of this simplification is that, because the library’s documentation needs explain fewer cases for failure, it is now shorter and somewhat more digestible.
Introducing concrete error types in a new subpackage ¶
Defining some errors out of existence allowed me to reduce the set of concrete error types to no more than eight orthogonal elements:
type IncompatibleOriginPatternError
type IncompatiblePrivateNetworkAccessModesError
type IncompatibleWildcardResponseHeaderNameError
type MaxAgeOutOfBoundsError
type PreflightSuccessStatusOutOfBoundsError
type UnacceptableHeaderNameError
type UnacceptableMethodError
type UnacceptableOriginPatternError
Recall that most importers of github.com/jub0bs/cors have no need for programmatic handling of their CORS-configuration errors, though. Because I didn’t want to overwhelm them, I decided to export the concrete error types, not from the root package, but from a new subpackage named “cfgerrors”. Thanks to this approach, the API of github.com/jub0bs/cors remains tight, and casual importers of won’t unnecessarily suffer additional cognitive load.
Package github.com/jub0bs/cors/cfgerrors
also provides an iterator factory named “All”,
which allows programmes to iterate
over the CORS-configuration errors contained in an error
value’s tree:
func All(err error) iter.Seq[error]
To benefit from those features, update to v0.5.0 of jub0bs/cors.
A relatively simple example ¶
The server below lets tenants configure which Web origins are allowed by their CORS middleware and whether credentialed access (e.g. with cookies) is allowed. It programmatically handles any resulting error in order to inform tenants of their CORS-configuration mistakes in a human-friendly way.
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"mime"
"net/http"
"github.com/jub0bs/cors"
"github.com/jub0bs/cors/cfgerrors"
)
func main() {
app := TenantApp{id: "jub0bs"}
mux := http.NewServeMux()
mux.HandleFunc("POST /configure-cors", app.handleReconfigureCORS)
api := http.NewServeMux()
api.HandleFunc("GET /hello", handleHello)
mux.Handle("/", app.corsMiddleware.Wrap(api))
if err := http.ListenAndServe(":8080", mux); err != http.ErrServerClosed {
log.Fatal(err)
}
}
type TenantApp struct {
id string
corsMiddleware cors.Middleware
}
func (app *TenantApp) handleReconfigureCORS(w http.ResponseWriter, r *http.Request) {
mediatype, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediatype != "application/json" {
w.WriteHeader(http.StatusBadRequest)
return
}
var reqData struct {
Origins []string `json:"origins"`
Credentials bool `json:"credentials"`
}
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
cfg := cors.Config{
Origins: reqData.Origins,
Credentialed: reqData.Credentials,
}
if err := app.corsMiddleware.Reconfigure(&cfg); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
var resData = struct {
Errors []string `json:"errors"`
}{
Errors: adaptCORSConfigErrorMessagesForClient(err),
}
if err := json.NewEncoder(w).Encode(resData); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
}
func adaptCORSConfigErrorMessagesForClient(err error) []string {
var msgs []string
for err := range cfgerrors.All(err) {
switch err := err.(type) {
case *cfgerrors.UnacceptableOriginPatternError:
var msg string
switch err.Reason {
case "missing":
msg = "You must allow at least one Web origin."
case "invalid":
msg = fmt.Sprintf("%q is not a valid Web origin.", err.Value)
case "prohibited":
msg = fmt.Sprintf("For security reasons, you cannot allow Web origin %q.", err.Value)
default:
panic("unknown reason")
}
msgs = append(msgs, msg)
case *cfgerrors.IncompatibleOriginPatternError:
var msg string
switch err.Reason {
case "credentialed":
if err.Value == "*" {
msg = "For security reasons, you cannot both allow credentialed access and allow all Web origins."
} else {
const tmpl = "For security reasons, you cannot both allow credentialed access allow insecure origins like %q."
msg = fmt.Sprintf(tmpl, err.Value)
}
case "psl":
const tmpl = "For security reasons, you cannot specify %q as an origin pattern, because it covers all subdomains of a registrable domain."
msg = fmt.Sprintf(tmpl, err.Value)
default:
panic("unknown reason")
}
msgs = append(msgs, msg)
default:
panic("unknown configuration issue")
}
}
return msgs
}
func handleHello(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, "Hello, World!")
}
Granted, writing such glue code is fastidious, but not prohibitively so, in my opinion.
Try it for yourself! After starting the server, run the following shell command:
curl -v localhost:8080/configure-cors \
-H "Content-Type: application/json" \
--data '{"origins":["*"],"credentials":true}'
The server responds to the resulting POST request as follows:
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Wed, 15 Jan 2025 17:50:02 GMT
Content-Length: 106
{"errors":["For security reasons, you cannot both allow credentialed access and allow all Web origins."]}
A more elaborate example, involving more of the concrete error types exported by package cfgerrors, is available in the documentation.
Eating my own dog food ¶
Before v0.5.0, some assertions in jub0bs/cors’s test suite relied on the precise wording of the library’s error messages. Those assertions had always bothered me, for two reasons:
- Maintenance burden: Even a slight change to the contents of error messages would require a corresponding change to those assertions.
- Misleading contract: Assertions used in black-box tests should ideally express the guarantees that importers can depend on; no more, no less. Assertions on error messages may give users the wrong impression that they can safely parse error messages without fear of seeing their code break when they update their dependencies.
With v0.5.0, I was able to refactor jub0bs/cors’s test suite and only assert on concrete error types and the programmatically accessible data they contain, not on the precise wording of their messages.
Call for sponsors ¶
If this feature is useful to your company, please do let me know. And if you depend on jub0bs/cors and would like to support its development and maintenance, consider sponsoring me on GitHub. 💸
API designCORSGoconfigurationcross-origin resource sharingerrorslibrarymiddlewareoriginsecurity
1757 Words
2025-01-28 14:40 +0000