RFC - Secure Services by default
See original GitHub issueAs discussed in the 2021-11-12 Core Meeting, we want to do what we can to make services secure by default. Currently, if you don’t add something like requireAuth()
manually in a services function, any GraphQL endpoints that resolve to those services will happily execute by anyone on the internet. That could be bad.
This issue will serve as a single place to discuss different ways to make services secure by default. Here “by default” means that if you did not do anything special to explicitly allow access, your service/endpoint would be secure—return an error when attempting to access with no authorization/authentication provided.
Current State
Given the following service function:
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
And the following GraphQL SDL:
export const schema = gql`
type User {
id: Int!
name: String!
email: String!
}
type Mutation {
deleteUser(id: Int!): User!
}
Anyone on the internet could perform this mutation and delete every user in the database:
import { request, gql } from 'graphql-request'
const query = gql`
mutation DeleteUser($id: Int!) {
deleteUser(id: $id) {
id
}
}
`
for (let i=0; i<100000; i++) {
try {
await request('https://insecuresite.com/.netlify/functions/graphql', query, { id: i })
} catch (e) {
console.log(`User ${i} not found, trying next...`)
}
}
Note that a service can be used internally as well, and the same security implications apply.
This can be prevented by simply adding a requireAuth()
call inside the service to check for proper permission (either being logged in at all, or the user having a specific role):
export const deleteUser = ({ id }) => {
requireAuth({ roles: ['admin'] })
return db.user.delete({
where: { id },
})
}
However, having to add it manually is rife for human error. We want Redwood to be secure by default and help save users from themselves.
Proposal A
Use some form of requireAuth()
behind the scenes and add a new function like allowAccess()
to explicitly allow access.
Assuming the same service:
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
Any access to this service would raise an error. You would need to explicitly allow access:
export const deleteUser = ({ id }) => {
allowAccess({ roles: ['admin'] })
return db.user.delete({
where: { id },
})
}
Perhaps just calling allowAccess()
(without arguments) allows any logged in user to access.
Some downsides to this proposal:
- Before adding
allowAccess()
calls, it’s not immediately clear that any kind of authorization is happening at all, these just appear to be regular functions - Not backwards compatible with existing codebases
Proposal B
This proposal addresses some of the concerns in Proposal A. This solution would be backwards compatible (since it uses the same requireAuth()
function we already know and love). We add a convention of exporting a function that will automatically run before all services so you only need to declare requireAuth()
once:
export const beforeService = () => {
requireAuth({ roles: ['admin'] })
}
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
If you have multiple service functions and want to not require auth for one or more of them:
export const beforeService = () => {
requireAuth({ roles: ['admin'] }, except: ['listUsers'])
}
export const listUsers = () => {
return db.user.findMany()
}
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
Or the opposite where you only want to lock down a few functions:
export const beforeService = () => {
requireAuth({ roles: ['admin'] }, only: ['deleteUser'])
}
export const listUsers = () => {
return db.user.findMany()
}
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
This proposal has the added benefit of letting you execute additional code that you always want to occur before a group of services (logging the API call, perhaps):
import { logAccess } from 'src/services/logger'
export const beforeService = ({ context, serviceName }) => {
requireAuth({ roles: ['admin'] }, only: ['deleteUser'])
logAccess(context.currentUser, serviceName)
}
export const deleteUser = ({ id }) => {
return db.user.delete({
where: { id },
})
}
What if the service does not export a beforeService
function? We have a few options:
- Raise an error: if you decide you do not need authorization in your service then you need to make it an explicit, conscious decision by exporting a
beforeService()
that returns nothing - Not require a
beforeService()
call, but throw an error or at least a warning if we do not encounter arequireAuth()
function call in a service - Require an explicit function call like
skipAuth()
call to really make it clear what you’re doing
Conclusion
Please add a comment if you have any additional syntax ideas!
Issue Analytics
- State:
- Created 3 years ago
- Reactions:5
- Comments:44 (39 by maintainers)
I like a lot of what I see in Option 2.
requireAuth
into each one and leave yourbeforeService
empty.I’d probably expect a missing
beforeService
not to error and just disallow access to everything.One question here: how does it apply to non-“resolver” functions in a service that are called from other places in the service or other services?
I think a good way to explore this further will be to think about what changes we’d need to make to the tutorial.
Got the prompt working!
--force
it won’t bother askingn
then it behaves the same as if the--force
flag was missing (refuses to overwrite files)y
then it basically flipsforce
totrue
for the tasks that involve overwriting files