Test and Simulation
Security note: the TaS service has no built-in authentication. Any client that can reach its network ports can use it. Do not expose it to untrusted networks. See the Security section before deploying.
For a safe local start, bind the published ports to localhost (loopback only):
docker run --name my-tas -d -p 127.0.0.1:1883:1883 -p 127.0.0.1:1880:1880 -p 127.0.0.1:3004:3004 ghcr.io/montimage/tas:v1.0.2
Then access to the tool at the address: http://127.0.0.1:3004
A MQTT broker server at the address: 127.0.0.1:1883,
A nodered server at the address: http://127.0.0.1:1880, and the nodered dashboard at the address: http://127.0.0.1:1880/ui
If you need other hosts on a trusted private network to reach the service, replace
127.0.0.1 with the machine's private interface address. Do not publish these ports to
0.0.0.0 or to the public internet while the service has no authentication.
cd tas/
npm install
Customize dashboard address
Create .env file: cp env.example .env
Update the host and port then start the application.
Configuration and credentials The
.envfile is git-ignored and never tracked in the repository or baked into the published Docker image (it is listed in.dockerignore). It is the documented place for machine-specific values and any credentials (MongoDB URIs, MQTT passwords, API keys). Usecp env.example .envto create it locally, and never commit a.envfile. Without a local.env, the server starts with the safe defaults fromenv.example.
Start the application
npm run start
Access to the Test and Simulation Enabler dashboard at: http://your_ip:3004
After starting the application, the Data Storage need to be configured to connect with a MongoDB server
- Open the browser and go to the application at: http://your_ip:3004
- Go to the Tab
Data Storageand update the parameter for connecting to a MongoDB Server
A MongoDB Server can be set up easily with docker:
docker run --name mongo-server -d -p 27017:27017 mongo
Every endpoint answers with the HTTP status code that describes the outcome, so a client, a reverse proxy or a monitor can tell a served request from a failed one without parsing the body.
| Status | When |
|---|---|
2xx |
The request was served. |
400 |
The request was malformed — a field of the wrong type, a name that cannot derive a safe file path, a document the database refused. |
401 |
The request carries no valid session. Log in at POST /api/auth/login; a session that has expired or been logged out reads the same. |
403 |
The request came from an origin that is not in CORS_ALLOWED_ORIGINS, or a state-changing request did not carry a valid X-CSRF-Token. |
404 |
The addressed model, data recorder, log, data set, event, report, test case, test campaign or API path does not exist. |
409 |
The request conflicts with the current state — starting a simulation or a data recorder that is already running. |
413 |
The body is larger than BODY_LIMIT. |
415 |
The request carries a content encoding the server cannot read. |
429 |
The client is over RATE_LIMIT_MAX for the current window. |
5xx |
The server failed (500), or a dependency such as the database is not reachable (503). |
Every failure carries the same JSON body, produced by one central handler
(src/server/middleware/errors.js):
{ "error": "Validation failed", "details": [{ "location": "body", "field": "model.name", "message": "\"model.name\" must be a string", "type": "string.base" }] }error is a message chosen for the caller and safe to display; details is
present only for a validation failure, where it names each refused field. A
response body never carries a stack trace, a server filesystem path or the raw
underlying error — that detail is written to the server log instead, where it
stays available for diagnosis.
The suite starts a real server instance (and, for the container checks, builds the image) and drives it over HTTP:
# HTTP assertions: path containment, name sanitisation, CORS, rate/body limits,
# and legitimate topology flows. Run serialised - each file spawns its own real
# instance against the same storage root.
node --test-concurrency=1 --test test/e2e/security-suite.test.js test/e2e/limits.test.js
# Container assertion: the built image must run its processes as a non-root user
docker build -t montimage/tas:e2e .
TAS_IMAGE=montimage/tas:e2e node --test test/e2e/container-nonroot.test.js
These assertions require the security fixes (path containment, CORS allowlist,
body-size and rate limits, non-root image) to be present, and are enforced in
CI on every push to master and every pull request via
.github/workflows/e2e-security.yml.
npm test runs everything under test/, including the end-to-end files, and
is serialised with the same --test-concurrency=1 for the same reason. Some
end-to-end assertions drive routes that need a database; with none reachable
they wait out the connect timeout, so a full local run takes roughly half a
minute even though the assertions themselves are fast.
Source: https://www.docker.com/blog/multi-arch-images/
- Enable
buildx: - Create new build engine:
docker buildx create --name mybuilder
docker buildx use mybuilder
docker buildx inspect --bootstrap
- Build a new image for multiple platform
docker buildx build --platform linux/amd64,linux/arm64 -t image_name:tag --push .
By default, there is a simple example of Temperature Controller The simple IoT network is figured in the following photo:
The Topology can be accessed on the GUI of TaS. The noderedflow is the default flow when open the nodered application
Every API endpoint requires an authenticated session. TaS is single-tenant: there is one administrator account, and it is provisioned from configuration rather than from anything in this repository, so a checkout never carries a working credential and two deployments never share one.
The preferred form is a pre-computed hash, so the plaintext only ever exists on the machine where it was generated:
node -e "console.log(require('./src/server/auth/passwords').hashPassword(process.argv[1]))" 'your-password-here'
# scrypt$16384$8$1$<salt>$<hash>
Put the result in AUTH_ADMIN_PASSWORD_HASH:
AUTH_ADMIN_USERNAME=admin
AUTH_ADMIN_PASSWORD_HASH=scrypt$16384$8$1$...
For a first start, AUTH_ADMIN_PASSWORD accepts a plaintext instead. It is
hashed once at startup and the plaintext is discarded immediately — it is erased
from the configuration object as it is hashed, so it is never stored, never
logged and never returned — but it does sit in the environment of the running
process, which is why the hashed form is preferred.
With neither set the server still starts, says so loudly on stderr, and refuses every API request: an appliance that cannot be configured is safer than one that opens itself up.
The allowlist is explicit, lives in one place (src/server/middleware/auth.js)
and holds exactly three endpoints:
| Endpoint | Why it is public |
|---|---|
GET /api/health |
Liveness probe for an orchestrator or monitor. Reports {"status":"ok"} and deliberately nothing else — no uptime, version or dependency state. |
POST /api/auth/login |
The endpoint that issues a session. |
GET /api/auth/session |
Lets the dashboard ask whether it is logged in. Answers 200 either way, so a cold start is not a 401 storm. |
A genuine CORS preflight — an OPTIONS request carrying both an Origin
and an Access-Control-Request-Method header — is also answered without a
session, because a preflight carries no credentials by definition. A bare
OPTIONS is not exempt: it is refused with 401 like anything else, so an
anonymous caller cannot read an Allow header off every path and map which
endpoints exist and which methods they accept.
POST /api/auth/logout is not on the list: logging out acts on a session,
so it needs one.
The static dashboard bundle and the single-page app shell stay public as well. That is deliberate — the login page is part of that bundle, so gating it would leave a browser with nothing to log in with. The bundle contains no operational data: every value the dashboard displays comes from the API, which is closed.
POST /api/auth/login with {"username": "...", "password": "..."} answers
200 and sets two cookies:
tas.sid— an opaque, signed,HttpOnly,SameSite=Laxsession identifier. The session itself lives server-side, which is what makes it revocable.tas.csrf— the CSRF token for that session. Readable by script on purpose (see below).
Sessions expire two ways. SESSION_TTL_MS (default 1 hour) is an idle
timeout that slides forward on every request, so working in the dashboard never
logs you out mid-task; SESSION_ABSOLUTE_TTL_MS (default 12 hours) is a hard
cap that does not slide. POST /api/auth/logout invalidates a session
immediately, and replaying its cookie afterwards answers 401. Sessions are
held in the process, so a restart ends all of them.
The session table also has a hard size cap, SESSION_MAX_RECORDS (default
1000): when it is full the least recently seen record is evicted. Expiry alone
is not a bound — nothing stops records being minted faster than they age out —
and a single-tenant appliance never has a thousand live operator sessions, so
the cap only ever bites on runaway traffic.
Every state-changing request (POST, DELETE) must carry the session's token
in an X-CSRF-Token header:
curl -X POST http://127.0.0.1:3004/api/models \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $(...value of the tas.csrf cookie...)" \
-b cookies.txt -d '{"model": {"name": "demo", "devices": []}}'
A browser attaches the session cookie to any request that reaches this origin,
including one an unrelated page caused, so the cookie alone cannot be what
authorises a write. A cross-site page can cause the cookie to be sent but the
same-origin policy stops it from reading it, so it can never produce the
header. POST /api/auth/login is exempt, because it is what issues the token.
Four endpoints change state over GET and therefore need the header as well:
GET /api/devops/startandGET /api/devops/stopGET /api/simulation/stop/:fileNameGET /api/data-recorders/stop/:fileName
SameSite=Lax does not cover these on its own — it deliberately still attaches
the cookie to a top-level GET navigation, so a link on any page an operator
visits while logged in would otherwise start or stop a campaign. The dashboard
sends the token on every request, so nothing in the UI has to know the list.
GET never requires the header.
A deployment already behind an authenticating proxy can let the proxy assert who the caller is. Two settings must agree, because a header on its own is forgeable by anyone who can reach the port:
AUTH_TRUST_PROXY_HEADER=true
AUTH_TRUSTED_PROXIES=10.0.0.7 # the proxy's address, as TaS sees it
AUTH_PROXY_USER_HEADER=x-forwarded-user # optional; this is the default
The header is honoured only when the connection comes from an address on
AUTH_TRUSTED_PROXIES. With the flag on and the list empty the feature stays
disabled and the server warns at startup. With the flag off — the default — the
header is completely inert.
The proxy must strip the identity header from incoming requests, or a client can set it itself. For example, with nginx:
location / {
auth_request /oauth2/auth;
proxy_set_header X-Forwarded-User $upstream_http_x_auth_request_user; # set, never pass through
proxy_pass http://127.0.0.1:3004;
}
A browser needs nothing more: the first request establishes a session and the
cookies, and the dashboard boots straight into GET /api/auth/session, which
reports the delegated identity.
CSRF still applies to a delegated request. Delegation is deliberately not an
exemption: the proxy attaches the identity header to whatever reaches it,
cookies or no cookies, so a cross-site forged POST arrives in exactly the
shape of a cookieless delegated request. A non-browser client behind the proxy
must therefore fetch its token first and echo it back:
# 1. ask who the proxy says we are, and collect the token for that session
TOKEN=$(curl -s http://127.0.0.1:3004/api/auth/session | sed 's/.*"csrfToken":"\([^"]*\)".*/\1/')
# 2. now a write is accepted
curl -X POST http://127.0.0.1:3004/api/models \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $TOKEN" \
-d '{"model": {"name": "demo", "devices": []}}'
Every such cookieless client shares one session record per asserted identity — the record is reused rather than re-minted per request, so a monitoring probe behind the proxy cannot grow the session table.
Failed logins are limited to AUTH_LOGIN_RATE_LIMIT_MAX (default 10) per
AUTH_LOGIN_RATE_LIMIT_WINDOW_MS (default 15 minutes) per client, after which
the endpoint answers 429. Successful logins do not count towards it, so a
working dashboard is never locked out by its own traffic. Every attempt, failed
or successful, is written to the server log with the attempted username, the
client address, the user agent and a running count of consecutive failures — the
password never is.
The API is authenticated, but the safe baseline is still defence in depth:
- Terminate TLS in front of TaS. Set
SESSION_COOKIE_SECURE=truewhenever TLS reaches the application itself; the default isfalsebecause the shippeddocker runspeaks plain HTTP on loopback, where aSecurecookie would never be sent at all and nobody could log in. - Set
SESSION_SECRETto a long random value. Without it the server generates an ephemeral one per process, which means every session ends at a restart. - Bind published ports to loopback (
127.0.0.1) or to a trusted private network interface rather than to0.0.0.0, unless the service is genuinely meant to be reachable from elsewhere. - The MQTT broker and Node-RED are not covered by any of this. They have no credentials of their own, so anything that can reach their ports can still publish, subscribe and edit flows — keep those ports off untrusted networks.
The quick-start docker run on this page already binds to loopback.
The hardening limits are configurable. Every value below has a safe default, so an unconfigured deployment is already protected — set these only to relax or tighten a limit.
| Variable | Default | Purpose |
|---|---|---|
CORS_ALLOWED_ORIGINS |
(empty) | Comma- or whitespace-separated list of origins allowed to call the API from a browser. Empty means same-origin only, and a request from any other origin is rejected with 403. |
BODY_LIMIT |
1mb |
Largest request body accepted. Anything bigger is rejected with 413 rather than buffered. MAX_BODY_SIZE is accepted as an alias. |
RATE_LIMIT_WINDOW_MS |
900000 (15 min) |
Length of the rate-limiting window applied to /api. |
RATE_LIMIT_MAX |
1000 |
Requests allowed per window per client. Going over returns 429. |
CSP_REPORT_ONLY |
true |
Ship the Content Security Policy as Content-Security-Policy-Report-Only, so browsers report violations without blocking. Set to false to enforce the policy. |
CSP_REPORT_URI |
(empty) | Endpoint browsers should POST policy violation reports to. Empty means violations are only visible in the browser console. Must be a single URL: ;, ,, whitespace and control characters are refused at startup. |
AUTH_ADMIN_USERNAME |
admin |
The single administrator account name. |
AUTH_ADMIN_PASSWORD |
(empty) | Plaintext bootstrap password. Hashed once at startup and then discarded. Empty means no credential is configured, and every API request is refused. |
AUTH_ADMIN_PASSWORD_HASH |
(empty) | Preferred: a scrypt$... value produced by hashPassword (see above). Takes precedence over AUTH_ADMIN_PASSWORD. |
SESSION_SECRET |
(none) | Secret the session cookie is signed with. There is deliberately no default: when unset, an ephemeral secret is generated per process and a warning is logged, so sessions do not survive a restart. Set it in production. |
SESSION_TTL_MS |
3600000 (1 h) |
Idle timeout. Slides forward on every request, so an in-use session is never logged out. |
SESSION_ABSOLUTE_TTL_MS |
43200000 (12 h) |
Hard lifetime. Does not slide: no session outlives it, however busy it is. |
SESSION_MAX_RECORDS |
1000 |
Hard cap on how many session records are held at once. When it is full the least recently seen record is evicted, so the table stays bounded whatever the traffic. |
SESSION_COOKIE_SECURE |
false |
Mark the session cookies Secure. Set to true whenever TLS reaches the application; the default suits the documented plain-HTTP-on-loopback baseline, where a Secure cookie would never be sent. |
AUTH_TRUST_PROXY_HEADER |
false |
Believe an identity header from an authenticating reverse proxy. Ignored unless AUTH_TRUSTED_PROXIES is non-empty. |
AUTH_PROXY_USER_HEADER |
x-forwarded-user |
Name of that identity header. |
AUTH_TRUSTED_PROXIES |
(empty) | Comma- or whitespace-separated peer addresses whose identity header is honoured. Empty means delegation stays disabled whatever the flag says. |
AUTH_LOGIN_RATE_LIMIT_WINDOW_MS |
900000 (15 min) |
Window for the login-specific rate limit. |
AUTH_LOGIN_RATE_LIMIT_MAX |
10 |
Failed logins allowed per window per client. Successful logins do not count. |
Values are read from the process environment first, then from .env, then from
these defaults — so a container or a CI job can override a setting without
editing the operator's .env file.
CORS_ALLOWED_ORIGINS is only needed when the dashboard is served from a
different origin than the API. In the shipped image both are on the same port,
so the default is already correct and no configuration is required.
The server sends a Content Security Policy that is written out in full in
src/server/middleware/security-headers.js rather than inherited from the
middleware's defaults, and is derived from what the shipped dashboard bundle
actually loads: same-origin scripts plus the build's inline webpack runtime
(allowed by its SHA-256 hash, not by 'unsafe-inline'), same-origin styles plus
the inline styles the component library injects, data: images, same-origin
fetch calls, and a blob: worker for the embedded JSON editor. No third-party
origin is permitted.
It ships in report-only mode. A deployment that serves a differently-built dashboard would otherwise have it break on the first load with no warning, so the safe rollout is to watch for violations first and only then enforce:
- Deploy as shipped and load the dashboard. Violations appear in the browser
console (and at
CSP_REPORT_URI, if you set one). - If nothing is reported, set
CSP_REPORT_ONLY=falseto enforce the policy.
Point CSP_REPORT_URI at an external collector or a path outside /api. Reports
sent to /api/... are counted by the rate limiter described above, so a page in
violation can spend a client's whole request budget on reports and get its real
API calls rejected with 429.
One violation is known and expected: a bundled vendor library contains a
new Function("return this") fallback that browsers attribute to script-src.
It is short-circuited before it runs and wrapped in a try/catch, so the
dashboard is unaffected — do not answer it by adding 'unsafe-eval'.
Rebuilding the client changes the inline runtime script, and therefore its hash.
The hash is recomputed from src/public/index.html at startup, so a rebuild
needs no configuration change - but restart the server after one.
The upgrade to the current major version of the header middleware also adds
Referrer-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy,
Origin-Agent-Cluster and X-Permitted-Cross-Domain-Policies, and sets
X-XSS-Protection: 0 rather than 1; mode=block. The latter is a deliberate
upstream change: the legacy browser XSS auditor that header re-enabled was
itself exploitable and has been removed from every current browser. The policy
above is what replaces it. No header that was previously sent has been dropped.
See SECURITY.md for how to report a vulnerability privately. We ask that you do not disclose unknown issues on public channels before they are triaged.
Montimage
