-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ben's style and structure review #84
Comments
Okay, so I did the second half today after all. A few more "Major" points here, and some strong recommendations about the internal/api/handlers_users.goMajor: For discussion: are we sure we want to have the server generate a password? There's a fair number of tricks and traps doing this. I personally dislike (and NIST guidelines support me) when there are password composition rules like "must have an uppercase letter, blah blah blah". For people who use good password generators, it's a pain, and for people who don't, they're just going to add "1!" to the end of their password. NIST recommends not having "composition rules", and checking against password dictionaries instead. But in general it's not an easy problem. In addition, it looks like you're using But I'd recommend avoiding having the server generate password altogether. People that want a weak memorable password can still easily use "Password1" if they want, according to Minor: This is a bit yucky, in for i := range users {
users[i].Password = ""
} I'd recommend changing Similar in userAccount.Password = "" I recommend not fetching this at all, except in the one place (authorization) where you actually need it, and have a separate db method for that, for example Major: (possibly Catastrophic, I'm not sure). In this code block: if id == "me" {
claims, headerErr := getClaimsFromAuthorizationHeader(r.Header.Get("Authorization"), env.JWTSecret)
if headerErr != nil {
logErrorAndWriteResponse(headerErr.Error(), http.StatusUnauthorized, w)
}
userAccount, err = env.DB.RetrieveUserByUsername(claims.Username) You're falling through in case of Also, I'd just use the standard Major: It's a bit odd, and perhaps not the greatest from a security perspective, that you fetch all users' full details only to check if there are any users or not: users, err := env.DB.RetrieveAllUsers()
if err != nil { ... }
permission := "0"
if len(users) == 0 {
permission = "1" // if this is the first user it will be admin
} I'd recommend having an explicit Major: Looking at the above, I'd strongly recommend having named constants for the permissions values. Otherwise you'll accidentally forget which magic constant is which in some new code and get them the wrong way around. Somewhat related, this is more of a "role" (admin vs normal user) than "permissions" (can they access feature X or Y). Might be good to use that terminology. Major: There's also a race condition here: I realise it'd be unlikely in practice, but if it happens or if someone knew the weakness it'd be pretty bad, as they could create an admin user. Consider this: two users hit I would recommend finding a different way to allow setting up the first user. One way (probably not the best) would be to check Another way would be a dedicated Minor: In internal/api/middleware.goMajor: I was looking at if claims.Permissions == USER_ACCOUNT {
...
}
next.ServeHTTP(w, r) But what if I'd strongly recommend an exhaustic Overall I think there should be a thorough security review from someone with a twisted mind to try to poke holes in the auth. I've learned by experience it's easy to get wrong. Minor: Speaking of security, in if err != nil {
return true, fmt.Errorf("error converting url id to string: %s", err)
} I think that should be internal/config/config.goMinor: Why does Minor: It's weird that return Config{}, fmt.Errorf("config file validation failed: %w", err) Could make the format string a function-scoped Actually, I'd recommend not adding that context at all -- the context is usually added by the calling function (it's the same for all error paths). The context you want to add here is what operation is being performed, like Minor: internal/config/config_test.goMinor: Once again, I'd use test helpers instead of internal/metrics/metrics.goTook a quick scan at this, and don't have any comments (but it's not really my area of expertise). internal/metrics/metrics_test.goMinor: In internal/db/db.goI don't like ORMs, but I'd highly recommend using a slightly higher-level database library. As mentioned earlier, I really like sqlc, which takes your database schema and queries, and compiled them to the boilerplatey Go code you'd write by hand (it's still easy to read). The dev typically uses You can see what that looks like on the recent Canonical project I've used it in a personal project as well, and it's really nice. Part of the beauty of it is you can still type if err := row.Scan(&newUser.ID, &newUser.Username, &newUser.Password, &newUser.Permissions); err != nil { You can easily add Go-based validation code in a non-generated .go file too, as needed. Alternatively, you could use Canonical's recently-produced sqlair library. That takes a different approach using reflection rather than codegen, but avoids the Major: It's also inefficient in that it usually fetches more data than you need (consider the Password case mentioned earlier), though that's probably not an issue here. You should always list columns you need explicitly: Major: It looks like you're correctly parametrising all the user-provided values, but seeing Just hard-code the table names in your queries. Much easier to read, and table names almost never change. And if they do, you'll need a careful migration process anyway. I personally like SQL queries done as function-level That said, if you use Major: I see you're reusing your database-level structs in your API. This is normally bad practice, because it means you can't change your database schema without breaking your API. For example, say we wanted to store permissions differently in the database, but not break our API users. We couldn't, because we're reusing the Instead, have your // internal/api/types.go
type user struct {
ID int `json:"id"`
Username string `json:"username"`
Permissions int `json:"permissions"`
// note no "Password" field here -- even safer
}
func userFromDb(dbUser db.User) user {
apiUser := user{
ID: dbUser.ID,
Username: dbUser.Username,
Permissions: roleToPermissions(dbUser.Role),
}
return apiUser
} And then get rid of the Minor: "rowid" seems like a pretty quirky SQLite feature, a feature which the author of SQLite wishes didn't exist. From that page:
How about just using much more standard explicit Major: Stated elsewhere, but comparing error strings is a bad pattern: if err.Error() == "sql: no rows in result set" {
return newCSR, ErrIdNotFound
} You should just be able to use Also in the above, you should always return the zero value of the first return value when you're returning an error, so the caller doesn't get a half-filled thing. In this case the zero value would be Major: What you're doing with setting the certificate string to "rejected" or "" (for deleted) seems very error-prone. I've almost always regretted it when I've tried to overload a column with another piece of data or state. I'd strongly recommend having a separate column for Then in your functions which fetch valid CSRs, you'd add Major: Your Minor: It looks like you don't check Minor: I would call the "password" column "hashed_password" in the database, to make it very clear it's not a plaintext password. It's great that you're not storing them in plaintext, of course, but that naming keeps things explicit and obvious. |
Thank you very much for the review @benhoyt . I really appreciate it and we will start tackling the more pressing issues very soon. |
This comment will be used to track progress in those comments: cmd/notary/main.go
cmd/notary/main_test.go
internal/api/server.go
internal/api/server_test.go
internal/api/response.go
internal/api/middleware.go (minor)
internal/api/authorization_test.go
internal/api/handlers_certificate_requests.go
internal/api/handlers_certificate_requests_test.go
internal/api/handlers_health.go
internal/api/handlers_login.go:
internal/api/handlers_login_test.go
internal/config/config.go
internal/config/config_test.go
internal/metrics/metrics_test.gointernal/db/db.go
|
Thanks for the review @benhoyt, I'm glad to see there isn't anything catastrophic :D. |
Remaining ones are:
|
After meeting with Ben, here are the final remaining tasks:
|
I'm happy to see that all of @benhoyt 's review comments have been addressed. We should probably come back a year after the first review for another round (if he's up to it). |
Enhancement Proposal
Overall, this is nicely structured! Good package structure, excellent use of the standard library rather than tons of 3rd party depenendencies, some good tests, and so on. If this is your first Go project, well done!
I've written up a bunch of comments below, labelling each "Minor", "Major", or "Catastrophic" (none yet -- might have some next week :-). The difference between Major and Minor is a bit arbitrary, but I've mostly used Major for "strong recommendation" and Minor for "style stuff". That said, many of them would be quick refactorings or quick fixes, so I'd still recommend doing that to tidy up the style a bit.
A couple of the more interesting things:
db.go
yet. But instead of doing your own database row scanning and parametrisation, consider using sqlc (I've used it in personal projects and really recommmend it) or Canonical's own sqlair type mapper. I like sqlc because it's pre-generated from your db schema withgo generate
so you get nice type-safe structs. I can give you a demo of sqlc if you want.I haven't finished the review. I have some security concerns about how you're generating passwords -- but that's enough for today! I'll review
handlers_users.go
and the database code next week.Early notes from a quick scan of the database code:
SELECT *
is a bad idea in production code -- I'll explain why next week. I've also got some comments/concerns about the database schema.Overall, it'd definitely be worthwhile reading a couple of Go style guides in detail, obviously the Canonical one (from Ed Jones), and also Google's Go style guide is good.
cmd/notary/main.go
Minor: Why do you want the logs going to stdout? It's more typical to have logs going to stderr.
Minor: You might want to look into graceful shutdown, so that if SIGTERM is sent (or Ctrl-C is pressed), it allows a small amount of time for requests to finish before exiting. This is pretty easy to achieve in Go. There's an example of this pattern in the
Server.Shutdown
docs.cmd/notary/main_test.go
Minor: Would be nice if TestMain didn't install stuff into your go home directory. When we've done this, we've just done a
go build ./cmd/notary
to build the binary in the current directory, and then use that.Minor: It's better to follow the standard
err
naming (rather thanwriteCertErr
orwriteConfigErr
) and check the error after each call, even if a tiny bit more verbose:That said, it might be better to create a
cmd/notary/testdata
directory and have those cert and key files in there -- it makes the test file a bit cleaner, you won't need to write them out, andgo test
automatically changes the current directory to the package directory when running the test binary, so you can reference them with a relative path liketestdata/cert.pem
. You can probably get rid of all the temp directory and cleanup stuff inTestMain
in that case too.Major: For all of these errors, you should include the
err
value in the log message, so that you're not completely blind if something does go wrong. Similar throughout. For example, change the above to:Minor: Nice use of table-driven tests. However, you should use
t.Run
inside the test cases loop so that you can observe and select specific sub-tests with `-run=Foo/bar. I see you've done that in some other places, just not here.Minor: I don't think you need to save/restore
os.Args
here, as you're running a new command, not doing anything withos.Args
. You can remove this:Minor: In
TestNotaryFail
, you can probably useCmd.CombinedOutput
to avoid the Start/Wait/read-StdoutPipe and simplify the code.internal/api/server.go
Major: It's highly unusual (and confusing I think) to have a directory named something different from the Go package name (
internal/api
is the directory, but the package isserver
). Leading to you wanting to rename the import:Just rename the directory to
server
and avoid the confusion.Minor: In Go everything is prefixed with the package name, so you want to make use of that and avoid "stuttering" if you can. So if there's one main type, use
server.New()
rather thanserver.NewServer()
, anddb.New()
rather thandb.NewDatabase()
.For
server.NewNotaryRouter
, that should probably beserver.NewRouter
(it's redundant to include the project name).Minor:
Environment
seems like a confusing name (it's not environment variables). How aboutRouterConfig
orHandlerConfig
? (And renameNewRouter
toNewHandler
-- which is actually more what it is, as it does all the handling, not just routing.)Minor: In
NewServer
, why does creating the key pair return an error (good) but connecting to the database does alog.Fatalf
(not so good)?Minor: In
SendPebbleNotification
, this should be just a wrapped error, no need for errors/New+errors.Join:Should be just
fmt.Errorf
with%w
:I see you've done that elsewhere though.
internal/api/server_test.go
Minor: It's unusual to use
TestMain
in this way. Is it actually used for anything in this package? It looks like you're not actually using these files, and you could probably removeTestMain
altogether (it's usually not needed at all).If anything, it's much more common to have explicit setup helper functions, using
t.Helper()
andt.Cleanup()
, and call them at the start of each test that needs them. For example:internal/api/response.go
Major: It's usually better for API clients that expect JSON (and I think all of these API endpoints return JSON) to get JSON in the error case as well. You could do this in your
logErrorAndWriteResponse
function:The name of that is also a bit of a mouthful. Perhaps just
writeError
(logging is an internal detail). I'd also change the signature so you can usefmt.Sprintf
and formatted string, allowing you to include an error with%v
easily if needed. It's most common for these functions to takew
as the first arg. So maybe something like:internal/api/middleware.go
Minor:
C_STYLE_CONSTS
isn't usually a thing in Go.USER_ACCOUNT
can beUserAccount
or perhapsUserPermission
.Minor:
responseWriterCloner
should probably have anUnwrap() http.ResponseWriter
method. Read more about response controllers.Minor: It's a bit odd that all the middleware funcs take a generic
ctx *middlewareContext
, instead of just the values they need (for example metrics middleware taking the metrics type).Minor:
responseWriterCloner
is an odd name for this. What aboutstatusRecorder
?Major: Normally you'd only apply middleware to the handlers that need them. It's messy when you apply them to everything, because then you have to have ad-hoc checks inside the middleware (which should be agnostic to what the route is) like this:
If you apply middleware at the route level (or to a whole group of routes), you can avoid this ad-hoc mathching code in the middleware itself. For example:
You should be able to do something similar with the authMiddleware, pass in a "allowFirstAccount" or something, and only wrap the routes you need, to avoid this ad-hoc filtering in the middleware itself:
Similarly, this looks a bit problematic in
authMiddleware
-- because it meansAllowRequest
has a list of hard-coded routes (done with regex matching):It would be much cleaner to have more specific auth middleware like
adminOnly
andadminOrUser
, and wrap only the applicable routes in it, for example:This would allow you to avoid the kind of re-routing code in
AllowRequest
entirely. You might need to useRequest.PathValue("id")
inadminOrUser
to get the user ID from the matched URL.Major: Unless I'm misunderstanding, I think this might be a bug (though it'd likely cause a panic in the caller rather than be a security bug). In
getClaimsFromJWT
, iferr
is nil but the token is invalid, this will returnnil, nil
and the caller will assume there's no error:It should probably be more like:
internal/api/authorization_test.go
Minor: it'd be cleaner if this special case was a test struct field,
passwordMatch string
or something, instead of a special case that compares the description. (What if someone changes the description and forgets to update this?)Or just pull this out as a separate test function. Not everything needs to be a table-driven test if it's unwieldy.
internal/api/handlers_certificate_requests.go
Major:: Generally it's best not to include arbitrary
err.Error()
information in the API responses, in case something mildly sensitive is leaked. For example:For internal errors, I would log the full error, but just include something generic like "internal error" in the API response. Also make sure that you have good metrics/alerts on this in production, so you can easily find such errors (which generally "shouldn't happen").
I also find it helpful to make helpers for specific error handling that happens, for example, instead of the above, make a helper you just call as
internalError(w, err)
orbadRequest(w, err)
.Major: You have this "marshal and then write to response" pattern a lot:
It'd be good to extract this to a helper, so you can do this without so much boilerplate:
Major: API design point. It's usually best to have a "result container" in your API responses, so you can add top-level metadata like "error" or "pagination-cursor" or whatever later. So
writeJSON
could wrap everything in a struct something like so:The
writeJSON
function could take care of this wrapping for you, and you could have awriteJSONError
too that used this shape:Major: Testing error strings is really not great, unless you obsoluately have to:
SQLite error message are probably quite stable (but who knows), but a maintainer might modify the "csv validation failed" error to say "CSV validation failed", and then this check would silently fail.
The
mattn/go-sqlite3
library actually has good error codes for things like this. You'd check with:However, it's probably best to not let lower-level
sqlite3
errors leak through the database package to the api layer. You probably want to do that check in the db package and convert it to a custom error type likedb.ErrAlreadyExists
, defined at the package level like so:Oh, I see you already have an
ErrIdNotFound
-- yeah, use exactly that same pattern here -- you want to avoid error string matching like the plague.For the "csr validation error", you can do the same thing:
If you're finding all the database error checking is basically the same ... in one of my projects I have a helper
handleDatabaseError
that does all the different checks and returns the correct error (bad request, internal error, etc). Something like so:Which avoids a lot of boilerplate after database queries.
Major: As a consumer of the API, it's weird that some things return JSON, but
PostCertificateRequest
returns a raw integer:In this case
strconv.FormatInt(id, 10)
happens to be in JSON number format, but that's kind of by coincidence.I think all responses should be wrapped in
JSON
. It's usually best to have a standard "shape", for example{"result": ...}
-- see my comments aboutwriteJSON
above, and then you could use justwriteJSON(w, id)
.Minor: I know it's only a pseudo-domain, but you should probably use a "domain" that we own in the Pebble notify key:
Maybe:
Minor: Speaking of pebble notify (it's cool that you're using it BTW). If
conf.PebbleNotificationsEnabled
, you should probably check at startup thatexec.LookPath
can find thepebble
binary, so it doesn't fail much later at runtime and you potentially miss the failures. It seems to me if that's turned on it should be a hard failure.Major: It looks like it sends a "certificate/update" pebble notify even for the reject and delete cases. Shouldn't these be a different notice key?
internal/api/handlers_certificate_requests_test.go
Minor: For things like this:
I would use a little helper (you can run helpers at the package-level for variable initialisation) that actually used
json.Marshal
with a little struct type. It would make this safer/cleaner (what if some of the strings needs JSON-escaping?).internal/api/handlers_health.go
Minor: In
HealthCheck
, you have to write the headers/status before the content, so swap these two lines:It also wouldn't hurt to log the errors and avoid the "nolint" comments.
internal/api/handlers_login.go:
Minor: This literal is a bit hidden away in the code:
Might be nice to make a package-level named
const
.internal/api/handlers_login_test.go
Minor: Once again, better to do this kind of thing with a struct field in the test case, rather than ad-hoc string matching on the test description as a special case:
Or just pull this out as a separate test function. Not everything needs to be a table-driven test if it's unwieldy.
The text was updated successfully, but these errors were encountered: