Security report : reference¶
Every check of the APIs Security Report, with what it looks for and how to fix it.
How a hook is read¶
The report reads the code of a hook, it never runs it, and it judges what the code does, not its name or its comments. A hook scopes rows when the person behind the request reaches the filter :
// Scopes : the id of the person goes into the filter.
const me = g.req.auth.authAMDB.customer_id;
g.req.query.find = { $and: [g.req.query.find || {}, { customer_id: me }] };
// Does not scope : it reads the person to decide something, then writes a filter of its own.
// Every person still reads the orders of every other person (the active ones).
if (g.req.auth.authAMDB.role !== 'admin') {
g.req.query.find = { $and: [g.req.query.find || {}, { status: 'active' }] };
}
The report follows the id of the person through variables (me, scope), objects and helper functions, in all eight languages. A few more things decide what counts :
- A hook runs when it is active (
isActivetrue) ; one which does not say so does not run, and does not count. - A hook of an API (API level) runs before that API only : a hook of get all does not scope get by id, and the report lists the APIs it leaves open.
- A hook which picks APIs by their id scopes the ones it picks :
if (apiId === 'SCHEMA_GET_ALL') { … }leaves get by id, update by id … with the filter their caller sends, and the report lists them. Aswitchwith adefault: throw, or a refusal after the branches, refuses the APIs it does not handle : nothing is open there. TypeScript is read with itsifs,switches and earlyreturns ; the other languages with theirifs. - The person has to keep the rows to their own : a filter which gives the person as one choice of an
$orbeside the filter of the caller ({ $or: [find, { customer_id: me }] }, any caller sends a filter every row matches), or puts the person under$ne,$nin,$not,$gt..., lets the rows of everybody else through. The report reads it as no scoping and says why. - An API whose settings say
NO_ACCESSanswers nobody outside the admin panel : it is not looked at. A public one (IS_PUBLIC) asks for no token at all : its own finding says so. The access of an API is the one its settings say, else its collection's, else its database's. - A group whose
activeis false still grants : a request reads the groups of its API user by their names alone. The report counts its grants, and says it is switched off.
deep, find-join and master save reach nothing more : for a request of a person or an application, the rows of the related collection are read (and, for master save, changed) through its own APIs as that caller : with the group check of the caller, the pre and post hooks of that collection (its row scoping), and never in a collection whose settings say NO_ACCESS. Code which calls g.sys.db runs under its own rules : the hooks run when it passes skipHookRunning: false. A find carries no SQL of its own either : on the SQL instances every value of a query is a parameter of the statement.
What the report finds¶
Besides the code (see The code scan), these are the findings of the settings, by category :
| Finding | Severity | What it means |
|---|---|---|
| Row gate | ||
A collection not scoped to the caller (ROW_SCOPE_MISSING) |
critical when its owner column is sure, high or medium otherwise | No active pre-hook puts the person into the filter : every person reads every row |
The users table reachable (USERS_TABLE_EXPOSED) |
critical | The users table of an auth provider is reachable by the groups and not limited to the row of each person : every account can be listed |
A person can change their own account (USERS_TABLE_WRITABLE) |
high, medium without a groups column | The users table is limited to the row of each person, but the groups may write it : a person changes their own groups ("*" is every group) or password |
Rows of persons without a person token (ROW_SCOPE_NO_PERSON_TOKEN) |
high or medium | The rows have an owner, but the APIs ask for no person token : nothing can scope them |
No person token anywhere (NO_PERSON_TOKEN_ANYWHERE) |
high | No API of the account asks for the token of a person |
The scoping hook is off (ROW_SCOPE_HOOK_INACTIVE) |
high | The hook which would scope the rows is switched off |
Some APIs not scoped (ROW_SCOPE_PARTIAL) |
high | The hook scopes some families of APIs, or some APIs (a hook of one API), and not the others |
Aggregate / distinct bypass the scoping (ROW_SCOPE_BYPASS_APIS) |
high | They have no place for the filter of the owner, and nothing refuses them |
Some groups not scoped (ROW_SCOPE_GROUP_GATED) |
high | The hook runs for some groups only (groupNames), other groups reach the collection too |
A later hook undoes the scoping (ROW_SCOPE_UNDONE_BY_LATER_HOOK) |
high | A hook which runs after the scoping one gives the filter a new value |
Writes trust the owner sent (ROW_SCOPE_OWNER_NOT_STAMPED) |
medium | Saves keep the owner the caller sent : a person creates rows for someone else |
A weak merge (ROW_SCOPE_WEAK_MERGE) |
medium | The owner is put next to the filter of the caller with a spread, or in its place, not with $and |
Internal calls scoped too (ROW_SCOPE_NO_INTERNAL_GUARD) |
info | Custom APIs, schedulers and events which carry no person are scoped too |
| Public access | ||
A public collection or API (INSTANCE_API_PUBLIC) |
critical | IS_PUBLIC : anyone on the internet reads, writes and deletes the rows |
A public system API (SYSTEM_API_PUBLIC) |
critical for the sensitive ones, lower for the others | IS_PUBLIC in the settings of a system API : anyone runs a query, reads the secrets, makes the server call an address ... (the API which gives tokens is public by design and not reported) |
A public custom API (CUSTOM_API_PUBLIC) |
medium when it reads the databases, low otherwise | The custom API answers without a token |
| API gate | ||
Broad flags (GROUP_ALL_INSTANCES, GROUP_ALL_DATABASES, GROUP_ALL_COLLECTIONS, GROUP_ALL_INSTANCE_APIS, GROUP_ALL_SYSTEM_APIS, GROUP_ALL_CUSTOM_APIS, GROUP_ALL_WEB_SOCKET_EVENTS, GROUP_ALL_FIELDS) |
critical to low | A group reaches everything of a kind, including what is added later |
Sensitive system APIs granted (GROUP_DANGEROUS_SYSTEM_API) |
high | Execute plain query, Get secret keys, the cache APIs, the indexes, Call external API, Is valid connection string |
Bulk update / delete granted (GROUP_BULK_WRITE_APIS) |
medium | update-many and remove-by-query, which change every row a filter matches |
| Auth providers | ||
Passwords stored in clear (PROVIDER_PASSWORD_NOT_HASHED) |
critical | The schema of the users table neither hashes nor encrypts the password column |
Tokens of any application taken (SSO_AUDIENCE_NOT_CHECKED) |
high | Google without clientId, Azure with an empty audience : a token made for another application (which any site can get from its own users) is taken as a login |
Long lived tokens (PROVIDER_LONG_EXPIRY) |
medium | A token lives more than 30 days |
No groups column, no groups source, no condition (PROVIDER_NO_GROUPS_COLUMN, SSO_NO_GROUPS_SOURCE, PROVIDER_NO_CONDITION) |
low | Every person gets the groups of the API user, or a disabled account still logs in |
| Hygiene | ||
Unused groups and providers, API users without groups, Swagger docs on (GROUP_UNUSED, PROVIDER_UNUSED, API_USER_NO_GROUPS, API_USER_SWAGGER_ENABLED) |
info | Loose ends which hide the real problems |
A token of a DB token generator never carries the password of its person : it carries a fingerprint of it (made with the secret of the tokens, which never leaves the server), or nothing when the provider names a passwordChangedAtColumn, and a password which changes ends the tokens made before. There is nothing to find there.
Every language¶
A hook can be written in any language API Maker runs, and the report reads all of them the same way : a Python hook which sets g.req.query["find"], a Go hook which calls g.Req.Query.Set("find", ...) and a TypeScript hook which assigns g.req.query.find are the same scoping to it. The checks of the row gate (which identity is read, which families of APIs are filtered, whether the owner is merged with $and, stamped on writes, whether aggregate and distinct are refused, whether internal calls are let through) work in every language.
The fixes write code in the right language too. An API runs the code of one language on one runtime, its hooks included, so :
- a new row scoping hook is written in the language of the hooks which already run for the collection, and the language can not be changed in the dialog (another one would be refused when it is saved) ;
- when no hook runs for the collection yet, the language most of the code of the account is written in is proposed, and any of the eight can be chosen ;
- a hook which is rewritten, or given the guard of the internal calls, stays in its own language.
The code scan¶
Settings tell who may call what. The code decides what an API then does, and it can leak data on its own : a custom API which returns the password column, a hook which takes the owner from the body, a query put together from what the caller sends. The scan of the code reads every piece of code of the account, in every language :
- the custom APIs, and the hooks of the version in use of each,
- the hooks of the instances, databases, collections and APIs, before and after,
- the hooks of the system APIs,
- the schedulers, the listeners of the events, the WebSocket events, the utility classes, the process initializers and the database migrations,
- the functions of the auth providers.
Its findings are in the category Code. Each one names the piece of code, the line and the language, shows the lines, says why it matters and what to do, and links to the page where the code is edited. A custom API which answers without a token makes most findings one level more severe : anyone on the internet can call it.
| Finding | Severity | What it means | One click fix |
|---|---|---|---|
A secret in the code (CODE_HARDCODED_SECRET) |
high ; critical for a private key, an AWS access key, a live key of Stripe ; medium for a test key | A key, a password or a token is written in the code, so in git and every copy of it (the report shows it masked) | the finding shows the code which reads it from Secret Management, in the language of the code |
A secret returned (CODE_SECRET_RETURNED) |
critical | What getSecret gave is part of the answer |
|
Tokens returned (CODE_TOKEN_ECHOED) |
low | g.req.auth or the headers are part of the answer |
|
Sensitive values in the logs (CODE_SENSITIVE_LOGGED) |
medium | A token, a password or a secret is printed to the logs | |
The users table read with its passwords (CODE_USERS_TABLE_UNSELECTED) |
high in a custom API, medium elsewhere | A call reads the table of an auth provider without leaving its password column out | adds a select which leaves the column out |
The caller writes the filter (CODE_CALLER_CONTROLS_FILTER) |
high | The filter (or the whole query) of a call of g.sys.db comes from the request as it is |
|
Rows of persons read unscoped (CODE_OWNED_ROWS_UNSCOPED) |
high | A table whose rows belong to persons is read with no filter on its owner column | |
A row by an id the caller sends (CODE_ID_FROM_REQUEST) |
high for writes, medium for reads | The id comes from the request and the code never reads who the caller is | |
The body written as it is (CODE_MASS_ASSIGNMENT) |
medium | Every column the caller sends is written : the owner, a role, a price | |
Error stacks returned (CODE_ERROR_DETAILS_RETURNED) |
low | A stack trace is part of the answer | |
SQL put together from the request (CODE_SQL_INJECTION) |
critical | A value of the request is pasted into the text of a statement | |
Commands run (CODE_COMMAND_EXECUTION) |
critical with a value of the request, medium otherwise | exec, system, subprocess, Runtime.exec ... |
|
Code made at run time (CODE_DYNAMIC_EVAL) |
critical with a value of the request, medium otherwise | eval, new Function ... |
|
An address the caller chooses (CODE_SSRF) |
high | An outgoing call goes where the request says : the metadata of the cloud, the databases, the other services | |
A file the caller names (CODE_PATH_TRAVERSAL) |
high | A path of the request reaches a file of the machine | |
TLS checks off (CODE_TLS_VERIFY_DISABLED) |
high | rejectUnauthorized: false, verify=False, InsecureSkipVerify: true ... |
turns the checks back on |
Weak password hashes (CODE_WEAK_PASSWORD_HASH) |
medium | A password is hashed with MD5 or SHA-1 | |
Guessable random tokens (CODE_INSECURE_RANDOM) |
medium | A token or a code is made with Math.random, random.random, rand ... |
|
A hook which changes who the caller is (CODE_HOOK_FORGES_IDENTITY) |
critical | g.req.auth or g.req.isApiRequestFromUser is written by a hook |
|
A caller who turns the scoping off (CODE_HOOK_CALLER_BYPASS) |
high | A hook returns before it scopes the rows when the request holds a header, a parameter or a field |
The key of a finding names the rule, the piece of code and what was found, not the line : a finding stays the same finding (and a skip stays a skip) when lines are added above it.