Security
How the SPLA API protects logins, data and the monthly report. Written for security reviewers and for anyone who wants to know what happens with their password.
Short version
- Your password and client secret are used once, in memory, to get a CloudIQ access token. They are never written anywhere and never logged.
- The service keeps only that CloudIQ access token, in memory, for at most 55 minutes, and gives you a random session token for it. It stores only a hash of your session token.
- It can only do what your own CloudIQ login can do; CloudIQ checks every call.
- It cannot submit a report. Changing drafts is off unless CleverIT switches it on.
- It is reachable from the whole internet at
https://cloudiq-spla.cleverpoc.com, only over TLS with a normal certificate. SoftwareOne's own API is public too, so being public adds little for an attacker; strict limits on logins and requests apply per address, per user name and for the service as a whole, so the service cannot be used to try passwords at scale. - The demo (
/demo) only knows invented data; every login gets its own copy, forgotten after an hour.
What is stored where
| Data | Where | How long |
|---|---|---|
| Password, client secret | Nowhere. Used once to call CloudIQ, then dropped with the request. | Duration of the login request |
| CloudIQ access token | Process memory, inside the session | Until the session ends (max 55 min, idle 30 min, logout, restart) |
| Session token | Only its SHA-256 hash, as key of the session table in memory | Same |
| User name, organization ids | Session (memory) and audit log | Audit log: 13 months |
| Report contents | Not stored. Passed through from CloudIQ per request. | - |
| Audit events | /var/log/spla-api/audit.log |
13 monthly files |
There is no database. A restart forgets all sessions.
Login
- Option A: the OAuth2 resource owner password flow that SoftwareOne documents for its Customer API (client id + secret + user name + password -> access token). Option B: the partner gets the token itself and gives only the token, so the password never passes through this service.
- Two-factor authentication. The CloudIQ web portal asks for an authenticator code; the SoftwareOne API's password flow does not. This is how SoftwareOne built the API. This service does not weaken or strengthen it. Mitigations here: per-IP, per-user-name and service-wide limits on login attempts, and generic error messages that do not say which of the four values was wrong. Partners should use a dedicated API client per integration so it can be revoked on its own.
- Login answers never echo any of the submitted values.
- Option B tokens: the
expclaim is read (not verified) only to end our session no later than the token. The token is then checked by CloudIQ itself (GET organizations/) before a session is created, and on every call.
Sessions
- Session token:
spla_+ 32 random bytes (Pythonsecrets, URL-safe base64). Sent asAuthorization: Bearer. - Ends at the earliest of: 55 minutes (and 60 s before the CloudIQ token), 30 minutes idle, logout, restart, or a 401 from CloudIQ.
- At most 500 sessions at once (configurable) so memory cannot be exhausted.
Cache-Control: no-storeon all API answers.
Authorization
- CloudIQ decides what a login can see and change; the service passes the user's own token.
- In addition the service checks that the organization in the URL is one the login returned at login time, that
a report is an SPLA usage report (program 24, category 2) of that organization, and that a change targets a
draft (
Type1). Anything else is answered with 404 or 409, without revealing whether it exists.
Changes to CloudIQ
- Off by default (
SPLA_API_ALLOW_WRITES=false); a403 writes_disabledis returned and nothing is sent. ?dry_run=trueperforms all checks and reads but sends no change. Change calls refuse every other query parameter and DELETE refuses a body, so a misspelled dry run (dryRun,dry-run, adry_runin a body) can never become a real change.- Creating a month's draft is checked and sent under one lock, so two simultaneous requests cannot create two drafts for the same month (within this service).
- Only drafts can be changed. Before and after each change the report is read again; the body sent to CloudIQ
always keeps
Type1. If a report is not a draft after a change, anunexpected_status_changeevent is logged. - There is no code path that submits a report.
- Add/remove line uses a full
PUTof the report. Within this service, changes to one report are serialized by a lock; concurrent edits in the CloudIQ portal can still be overwritten (documented for partners).
Input checks
- JSON only, body at most 64 KB, unknown fields refused, numbers and month formats checked, quantities 0 to 1,000,000, part numbers and country codes by pattern, comment at most 250 characters.
- Requests with
password,client_secret,access_token,session_tokenortokenin the query string are refused (they would end up in proxy logs). - Ids in URLs are integers (route converters); no user input is put in CloudIQ paths as text.
- CSV export prefixes text cells that start with
=,+,-or@with'(spreadsheet formula injection). - Upstream error texts are shortened to 200 ASCII characters before they are returned.
Rate limits
Per IP: 20 login attempts per 10 minutes, 300 requests per minute (shared by everyone behind one office or RD
address). Per user name: 5 failed logins per 15 minutes, and at most 2 logins running at the same time (so
simultaneous guesses cannot pass the failure limit; successful logins are not limited). Per session: 120 requests per minute and at most 2 at the same
time. For the whole service: at most 100 failed password logins per 10 minutes (setting
SPLA_API_LOGIN_FAILURES_GLOBAL_PER_10MIN); while that ceiling is reached, password logins of user names that have
not logged in successfully through this service in the last 60 days are refused. On the public name, nginx in front of the service
also allows 10 requests per second per address (bursts of 40) and 20 open connections per address. Answers 429
with Retry-After.
Trade-offs of the limits on a public service: someone can block a user name in this service for 15 minutes with
five wrong passwords. Someone with many addresses can keep the service-wide ceiling reached for as long as they keep
trying; then new user names cannot log in with a password, but user names that logged in through this service in the last 60 days
keep working (still with their own limit of 5 failures), and logins with your own access token (option B) are never
under the ceiling: a token cannot be guessed. Nothing here locks the CloudIQ account. The ceiling exists so that
the service can never be used to try passwords against SoftwareOne at scale, and so that SoftwareOne has no reason
to block this server. The audit log shows it (rate_limited, scope login_failures_global); watch for it.
The list of known user names is kept as SHA-256 hashes in /var/lib/spla-api/known-users.json (readable only by
the service, at most 10,000 entries, 60 days). The hashes are not salted: someone who can read the file and guesses a
user name can check whether it logged in. The file holds no passwords, tokens or other data.
While the ceiling is reached, the answer differs for known user names (CloudIQ is asked: 401 or 201) and unknown ones
(429), so someone could learn which user names logged in through this service recently; each such check still costs that user name
one of its 5 allowed failures. Accepted: user names are not secret, and the alternative would lock out known
partners.
Network and TLS
- The service listens on a unix socket that only root, the service and processes running as
www-datacan open (group membership, mode 660); on this server the onlywww-dataprocess is nginx (checked 27-10-2026; keep it so, e.g. give a future PHP pool its own user). There is no TCP port. nginx terminates TLS and sets the client address; no other process on the server can go around nginx or fake a client address. The fake CloudIQ of the demo listens on 127.0.0.1:8092; it holds only invented data. - Public: port 443, name
cloudiq-spla.cleverpoc.com, Let's Encrypt certificate (TLS 1.2 and 1.3). The private key is made on the server and never leaves it; the certificate is renewed from CleverIT's admin machine, which proves ownership of the name in DNS. Other names on the same server (the Clever start pages) keep their own site and allow list; a smoke test after every installation checks that they do not reach this service. - Internal: ports 8443 (production) and 8444 (demo) with a self-signed certificate, only for CleverIT's own
systems on an allow list (nginx and ufw). Clients trust exactly that certificate (
curl --cacert). - Never switch certificate checking off (
curl -k,verify=False): the login sends passwords and secrets. - Outgoing: only HTTPS to
api.crayon.com, with a timeout. Plain HTTP to the upstream is refused unless explicitly allowed for the local fake CloudIQ. TLS certificates of CloudIQ are checked (Python default). Redirects are never followed: a 3xx answer is treated as "CloudIQ unavailable", so a token or client secret can never be resent to another host (tested). - Headers on every answer:
Content-Security-Policy(self only, no framing),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer; nginx addsStrict-Transport-Security.
Web pages: Try it and the API reference
- Try it (
/try) and the API reference (/swagger, Swagger UI) run in your browser and call the API on the same address; nothing goes to another site. The session token is kept only in the memory of that browser tab (not in cookies or local storage) and is gone when the tab closes. The service sets no cookies. - Answers from the API are shown as text only, never as HTML, so data from CloudIQ cannot run code on the page.
- Swagger UI 5.34.0 is served from this service itself (files unchanged from the npm package, checked against
the npm integrity hash, source noted in
web/static/swagger-ui/SOURCE.txt). Its online validator is switched off. The same Content-Security-Policy applies: scripts and styles only from this service, no inline script. - On the Try it page a real CloudIQ login is used exactly like a login from your own software. Use the demo for trying out; for real data prefer option B or a separate CloudIQ user.
Logging
- Audit log fields are listed in the administrator guide. Secret-looking field names are replaced by
[removed]before writing; request bodies are never logged; the session is identified by a 12-character hash prefix. - Production also writes every audit event to the systemd journal (prefix
AUDIT, written before the file), which the service user cannot change; forward the journal to a central log server for long-term, tamper-proof storage. If the audit file cannot be written, requests fail instead of running without a trace. request_idis always generated by the service; a client-suppliedX-Request-Idis only logged asclient_request_id, so a client cannot steer the audit trail.- Unexpected errors go to the journal with the
request_id, the exception type and the code location, but without the exception text or request bodies (those could contain a header value such as a token). - An access token (option B) must consist of the characters a token can have; anything else, such as a line break, is refused before it is used in a header or could reach a log.
- nginx access logs use their own format with the path only (
$uri), never the query string, and the error log of this site only takes critical messages (lower levels can contain the full request line). A secret wrongly put in a URL is refused by the service. Residual: when nginx cannot reach the service at all (socket missing), it logs that atcritlevel including the request line, in this site's error log.
Running it
- systemd: production, demo and fake CloudIQ each run as their own user (
splaapi,splaapi-demo, a dynamic user) with their own group, socket and log folder (mode 700); logrotate rotates as that user.NoNewPrivileges, no capabilities,ProtectSystem=strict,ProtectHome,PrivateTmp,PrivateDevices,ProtectProc=invisible,MemoryDenyWriteExecute,SystemCallFilter=@system-service,RestrictNamespaces,UMask=0077,MemoryMax, write access only to its log and socket folders; settings files are root-only (systemd reads them). - Dependencies are the distribution packages of Flask, waitress and Markdown (security updates through the OS).
Known limits and residual risks
| Risk | Status |
|---|---|
| CloudIQ API login has no second factor | SoftwareOne design. Mitigated by limits (also service-wide), option B, separate API clients. |
| Public: anyone can try to log in | Same exposure as SoftwareOne's own public API; limits per address, per user name and service-wide (see Rate limits). |
| Public: the service-wide login ceiling can be kept reached on purpose | New user names cannot log in with a password meanwhile; known user names (last 60 days) and option B keep working; visible in the audit log (rate_limited, scope login_failures_global). |
| Public demo used to fill memory | Every login has its own copy with at most 40 reports; at most 1000 logins are kept (about 50 MB); the fake CloudIQ restarts on its memory cap, which only logs out demo users. |
| Audit log keeps the user names of failed logins from anyone | Needed to see attacks; shortened to 100 characters, kept 13 months. A password typed into the user-name field would be logged; the login answer never echoes it. |
| Self-signed certificate on the internal ports | Internal clients trust exactly this certificate (no -k); the public name has a Let's Encrypt certificate. |
| Add/remove line overwrites concurrent portal edits | Documented; quantity changes use a row-level PATCH that does not have this. |
| Someone submits the draft in the portal between our check and our change | CloudIQ offers no version field to lock on. The report is read again just before and right after each change, and a report that is no longer a draft is not changed. Whether CloudIQ itself refuses changes to a submitted report is not tested (no live writes so far). |
| Write calls tested only against the fake CloudIQ | Writes are off in production until tested live with the owner watching. |
| Sessions lost on restart | Accepted: users log in again. |
| Root and nginx on the server can reach the sockets directly | Inherent; the server is managed by CleverIT only. |
| Outgoing traffic is not limited to CloudIQ addresses | CloudIQ addresses can change; possible later with IPAddressAllow= in the unit. |
Reporting a problem
Report suspected vulnerabilities to your contact at CleverIT, with the time and
request_id, or e-mail support@cleverit.nl. Please do not test against production
with real reports; use the demo (/demo).