Where
-Infinity
0
Infoleak

Cross-tenant subscription data leak in @graphql-tools/executor-legacy-ws: a shared websocket.onmessage is replaced on every subscribe(), and DATA frames are delivered without checking the protocol operation id

Report metadata

| Field | Value | | --- | --- | | Package | @graphql-tools/executor-legacy-ws (npm, pkg:npm/%40graphql-tools/executor-legacy-ws) | | Repository | https://github.com/ardatan/graphql-tools | | Component | buildWSLegacyExecutor → the returned executor's subscribe(observer) / websocket.onmessage handler | | Vulnerability class | Cross-tenant data leak (subscription results delivered to the wrong caller) | | Verified against | Source at b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1 (package version 1.1.33); published npm tarball 1.1.33 with matching source at d304214db06e8a4ce9cb41138f199e3bc6ed36f2; the same behaviour also reproduces on the current release 1.1.35 | | API surface | Public documented API (buildWSLegacyExecutor) | | Affected in default configuration | Yes — ensureWebsocket() reuses an already-OPEN socket (index.ts:35-42), and the socket has a single mutable onmessage property (index.ts:113). No single-operation-only restriction is documented, and the return type is the general reusable DisposableExecutor (packages/utils/src/executor.ts:34-57) |

Affected version range: the range recorded for this finding is <= 1.1.33, but that is understated — the same proof of concept reproduces on 1.1.35, and a byte diff of the built CJS output between 1.1.33 and 1.1.35 shows only one unrelated change (rejectUnauthorized).

Summary

buildWSLegacyExecutor keeps one WebSocket in closure state and installs the message handler by assigning to the socket's single websocket.onmessage property inside subscribe(). Every new subscription therefore overwrites the previous subscriber's handler, and the handler's case LEGACYWS.DATA branch calls observer.next(data.payload) without ever comparing data.id to the operation id that this subscription created at line 103 and sent with START at line 123. When one executor instance is reused for overlapping subscriptions, the most recent caller receives payloads belonging to the earlier caller, and the earlier caller receives nothing.

In a consuming application, the tainted input is an ordinary, correctly-tagged DATA frame emitted by the legitimate downstream server for the victim's operation. The attacker does not need to control or compromise that server; they only need to be able to start their own subscription through the same shared executor while the victim's subscription is active — the natural shape of a gateway, stitched schema, or any service that builds a remote executor once and reuses it per request.

The surrounding code shows the inconsistency:

- packages/loaders/url/src/index.ts:262-295 (in-repo UrlLoader wrapper) → constructs a fresh legacy executor inside each request, so that caller avoids the shared state. - buildWSLegacyExecutor's returned executor, reused across callers → unsafe: shared onmessage overwritten at line 113, DATA dispatched without an id check at lines 145-147. - packages/loaders/url/src/index.ts:172-182 → also a production caller of the legacy executor; safe only insofar as the wrapper above rebuilds the executor per request, not because of any check in the executor itself.

Impact

A user of a service that shares one legacy executor receives another user's private GraphQL subscription payloads verbatim, with the victim's authorization context, without ever having been authorized for that operation. The victim is simultaneously starved: their own iterator stops receiving the events they subscribed to, because their handler has been detached.

Without this bug, the attacker's observer would receive only results for their own operation — and in this implementation, no result at all, since their operation is never STARTed after CONNECTIONACK has already fired. The new capability is therefore reading data the attacker could not otherwise obtain by any request they are permitted to make, plus a denial of the victim's subscription stream.

Scoring note: no CVSS vector was computed for this finding, because exploitability depends on the consuming application's data flow — specifically whether one executor instance is shared across mutually untrusted callers. Consumers that construct a new executor per operation (as the in-repo UrlLoader wrapper does at packages/loaders/url/src/index.ts:262-295) are not affected by the cross-caller leak. The set of affected downstream consumers has not been enumerated: unknown.

Preconditions

1. One executor value returned by buildWSLegacyExecutor is reused rather than constructing a new executor per operation. This is the normal use of an Executor: the contract is invoked once per ExecutionRequest, the code explicitly reuses an OPEN socket, and the returned value holds Symbol.dispose. 2. Two subscription iterators overlap on an already-OPEN socket. The deciding default is in ensureWebsocket() (packages/executors/legacy-ws/src/index.ts:35-42), which does not create a second socket when one is open, combined with the single mutable websocket.onmessage assignment at line 113. 3. The attacker can cause their own subscription request to be made through that shared executor while the victim's subscription is active and acknowledged. They do not need any control over the downstream server. 4. Downstream subscription results differ by user or authorization context, so a misrouted payload is actually a disclosure.

This does not require non-standard usage, monkey-patching, or unusual configuration. packages/executors/legacy-ws/src/index.ts documents no single-operation-only restriction, the return type is the general reusable DisposableExecutor (packages/utils/src/executor.ts:34-57), and the behaviour is not documented as a caller responsibility.

Data flow

1. Source — a legitimate DATA message emitted by the downstream server for another operation on the shared WebSocket. The victim invokes the returned executor, and subscribe() installs a closure over the victim's observer and request as websocket.onmessage (packages/executors/legacy-ws/src/index.ts:90-113). 2. Propagation — the server's CONNECTIONACK starts the victim's operation with id, created at line 103 and sent with START at line 123 (packages/executors/legacy-ws/src/index.ts:116-135). 3. Sink — the attacker invokes the same executor while websocket.readyState is OPEN; ensureWebsocket() does not create a new socket and subscribe() replaces the sole websocket.onmessage with the attacker's closure (index.ts:35-42, index.ts:109-113). The server's DATA frame for the victim carries the victim's operation id per the legacy protocol, but the handler ignores data.id and calls observer.next(data.payload) on the attacker's observer (index.ts:114-147). 4. Reachability — the sink sits in the only subscribe path of the executor produced by the directly exported buildWSLegacyExecutor (index.ts:27-31). packages/loaders/url/src/index.ts:172-182 is a production caller in this repository; direct library consumers reach it by building a remote executor once and reusing it.

Vulnerable code

packages/executors/legacy-ws/src/index.ts, lines 103-167 at b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1:

ts const id = Date.now().toString(); return observableToAsyncIterable({ subscribe(observer) { ensureWebsocket(); ... websocket.onmessage = event => { // sole handler: overwritten by every new subscribe() const data = JSON.parse(event.data.toString('utf-8')); switch (data.type) { ... case LEGACYWS.DATA: { observer.next(data.payload); // SINK: data.id is never compared with id break; } } }; }, });

Two independent defects are visible here: the single mutable onmessage assignment (which detaches the earlier subscriber), and the missing data.id === id check in the DATA branch (which misroutes the payload). The operation id itself is also derived from Date.now(), so it is not collision-resistant.

Reproduction

Verified dynamically against the real published package @graphql-tools/executor-legacy-ws@1.1.33 (npm tarball with matching source at d304214db06e8a4ce9cb41138f199e3bc6ed36f2). The harness was a protocol-correct legacy graphql-ws server (ws v8) that tags every DATA frame with the operation id of the operation that produced it, plus two callers sharing one executor instance.

- Scenario A (control, single subscription): the subscriber received its own payload, confirming the harness is sound. - Scenario B (the claim): caller 1 ("victim") subscribed; the server ACKed and the client sent start id=1790247741390. Caller 2 ("attacker") then invoked the same executor while readyState was OPEN. The server observed only one socket (ensureWebsocket reused it) and only one started operation — the attacker's operation was never started, because CONNECTIONACK had already fired. The server then emitted a correctly-tagged DATA frame for the victim's id. The attacker's iterator received {"data":{"victimSecret":"VICTIM-PRIVATE-DATA-a1b2c3"}} and the victim's iterator received nothing. - Scenario C: a DATA frame tagged with the id "id-that-was-never-started" was still delivered to the subscriber, proving the protocol operation id is never validated. - Realistic wiring: with the executor wired into wrapSchema() the way a stitching gateway does, two concurrent users subscribing to the same field produced a verbatim cross-tenant leak — User B received {"data":{"accountBalance":"USER-A-PRIVATE-BALANCE-$91402.11"}} while User A received nothing. When the two users select different fields, the wrap layer's result transform masks the foreign field (B saw weather:null), but the misrouting still occurs and the victim is still starved; at the raw executor API the payload leaks verbatim regardless. - Causality control: patching only the DATA branch to check data.id === id eliminated the leak (the attacker received nothing, and the foreign-id frame was rejected), pinning the leak to the missing id check. Under that patch the victim still received nothing, confirming the second, independent defect: the single shared websocket.onmessage is overwritten on each subscribe(), detaching the earlier subscriber's handler entirely. - Scope caveat: the identical proof of concept also reproduces on the current latest release 1.1.35.

Minimal client-side shape:

ts import WebSocket from 'isomorphic-ws'; import { parse } from 'graphql'; import { buildWSLegacyExecutor } from '@graphql-tools/executor-legacy-ws';

const exec = buildWSLegacyExecutor('ws://localhost:8081/graphql', WebSocket);

const victim = exec({ document: parse('subscription { privateEvent { secret } }') }); const victimPending = victim.next(); // causes connection + START; wait until the server has ACKed await new Promise(r => setTimeout(r, 100));

const attacker = exec({ document: parse('subscription { publicEvent { id } }') }); const attackerPending = attacker.next(); // overwrites socket.onmessage

// When the server now emits DATA for the victim's id, this prints the victim's secret. console.log(await attackerPending);

Steps:

1. Start a normal legacy subscriptions-transport-ws server with a private event subscription. 2. Build one executor and call victimIterator.next() so the socket receives connectionack and START for the victim. 3. While that socket is OPEN, call attackerIterator.next() using the same executor. 4. Emit one victim event from the server.

- Expected: a DATA frame is delivered only to the observer registered for data.id; the attacker's pending next() must never resolve with the victim's payload, and the victim keeps receiving their own events. - Actual: the attacker's next() resolves with the victim's payload, and the victim's iterator receives nothing.

Suggested remediation

Dispatch protocol frames by operation id to the observer that started that operation, instead of routing every frame to whichever subscriber most recently overwrote the socket's sole onmessage handler.

ts // 1. Stop the misrouting: ignore frames that belong to another operation. case LEGACYWS.DATA: { if (data.id !== id) { break; } observer.next(data.payload); break; }

ts // 2. Stop the handler overwrite: keep a registry of observers keyed by operation id // and install exactly one shared message listener for the socket. const observers = new Map<string, Observer<any>>();

function ensureMessageHandler() { if (messageHandlerInstalled) return; messageHandlerInstalled = true; websocket.onmessage = event => { const data = JSON.parse(event.data.toString('utf-8')); const target = data.id != null ? observers.get(data.id) : undefined; // ... dispatch DATA / ERROR / COMPLETE to target only, and remove it on COMPLETE }; }

An alternative that keeps the current structure is to create a separate socket (and therefore a separate listener) per execution, as the in-repo UrlLoader wrapper effectively does today.

Notes:

- The same id check is needed on the ERROR and COMPLETE branches, not just DATA; otherwise one operation's termination still tears down another's iterator. - The operation id is generated as Date.now().toString() (line 103), which is not collision-resistant — two subscriptions created in the same millisecond would share an id even after the dispatch fix. Use a counter or a random component. - The missing id check and the overwritten onmessage are two independent defects; fixing only the id check stops the leak but leaves the earlier subscriber permanently starved, as the causality control above showed. - packages/loaders/url/src/index.ts:262-295 already builds a fresh legacy executor inside each request, so that caller needs no change; the fix belongs in the executor itself, because the exported buildWSLegacyExecutor returns a general reusable DisposableExecutor. - No upstream advisory (CVE/GHSA), fix commit, or fixed version is known for this issue: unknown / not verified. The set of affected downstream consumers has likewise not been enumerated.

Suggested regression test alongside the existing packages/executors/legacy-ws test suite: with one executor shared by two overlapping subscriptions on an OPEN socket, assert that a DATA frame tagged with subscription A's operation id resolves only A's iterator and never B's, and that a DATA frame tagged with an id that was never started is dropped rather than delivered to the most recent subscriber.

Resolution

Fixed in @graphql-tools/executor-legacy-ws@1.1.36, which is published on npm. Each execution has its own socket, and data, error, and complete frames are delivered only when the protocol operation id matches. Upgrade to >=1.1.36.

1 / 2
Source: GitHub
First published (updated )

Stale authentication state in @graphql-tools/executor-legacy-ws: per-request extensions.connectionParams persist in executor closure state and are replayed in a later caller's connectioninit

Report metadata

| Field | Value | | --- | --- | | Package | @graphql-tools/executor-legacy-ws (npm, pkg:npm/%40graphql-tools/executor-legacy-ws) | | Repository | https://github.com/ardatan/graphql-tools | | Component | buildWSLegacyExecutor (packages/executors/legacy-ws/src/index.ts) | | Vulnerability class | Stale authentication state / credential reuse across requests | | Verified against | Commit b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1; npm release 1.1.33 installed and exercised. During validation the same behaviour was also observed on the current latest release 1.1.35, and the unreset closure is still present on master. | | API surface | Public documented API (buildWSLegacyExecutor, and request.extensions.connectionParams on the returned executor) | | Affected in default configuration | Yes — the factory returns a general, reusable Executor with no per-identity or single-use restriction, and no supported teardown clears the retained parameters. |

Version note: the originally recorded affected range was <= 1.1.33. Validation showed that range understates exposure — the same proof of concept leaks identically on 1.1.35.

Summary

buildWSLegacyExecutor keeps a single closure-scoped object, executorConnectionParams, for the lifetime of the executor. Every execution that carries request.extensions.connectionParams merges those keys into that object with Object.assign, and the object is merged into the connectioninit payload each time a new WebSocket opens. Nothing ever removes keys: cleanupWebsocket terminates the socket and nulls the handle but leaves executorConnectionParams intact, and a later request that simply omits a key skips the conditional update entirely.

The consumer-side shape is a shared executor that forwards per-user credentials. When one long-lived executor serves multiple principals — the schema-stitching, gateway, or mesh pattern where each ExecutionRequest supplies its own extensions.connectionParams — a request that omits the auth key is sent to the downstream server with the previous caller's Authorization/tenant values in its connectioninit, so the downstream server authenticates the later caller as the earlier user.

Behaviour of the neighbouring state in the same file:

- WebSocket handle (websocket) → reset on teardown in cleanupWebsocket, lines 78-88 - Per-request connectionParams merge, present case → applied as documented, lines 93-101 - executorConnectionParams closure object → never reset; survives socket teardown and is re-sent on the next connection, line 32 and line 54

Impact

A caller who supplies no credentials (or lower-privileged ones) can have its downstream GraphQL subscription executed under a previous user's retained token or tenant value, and can receive data that its own connectioninit would not authorize. The credential itself is never disclosed to the attacker — it reaches the sink through closure storage, not because the attacker knows it — but the downstream authorization it represents is.

The new capability versus safe behaviour: without this bug, an anonymous request would open a connection whose connectioninit contains only base options plus that request's own parameters, and the downstream server would treat it as anonymous. With it, the anonymous request inherits the victim's authorization. Validation also showed partial-overwrite merging: a later caller supplying only {tenant: "<attacker tenant>"} gets the victim's Authorization retained alongside its own tenant, i.e. an attacker-chosen tenant authenticated with someone else's bearer token.

Scoring note: not scored. No CVSS vector accompanies this finding, and exploitability depends on the consuming application's data flow rather than on the library alone. Consumers that construct a fresh executor per request are not affected — the in-repository UrlLoader wrapper (packages/loaders/url/src/index.ts:172-182) builds a new executor for each request and therefore does not exhibit the persistence. The exposure is to applications that use the exported factory directly and share the executor.

Preconditions

1. One executor instance created by buildWSLegacyExecutor is reused across requests belonging to different users or authorization contexts. 2. The application uses the explicitly supported extensions.connectionParams field to carry per-request downstream identity. This field is documented only by the source comment at line 91, which says additional connection params can be supplied through extensions but does not warn that values persist into later requests. 3. The first operation's socket is closed before the later operation opens a new one. This is the normal path, not an edge case: the COMPLETE handler calls cleanupWebsocket() after every operation. 4. Attacker capability required: the ability to make a request through the sharing application — in a gateway, a remote user controls whether they authenticate and which subscription they ask for, and an anonymous request naturally leaves the connectionParams key absent. 5. The later request omits at least one sensitive key rather than overwriting every prior key (Object.assign merges, it does not replace).

This does not require non-standard usage, monkey-patching, or unusual configuration: the affected path is the documented extensions.connectionParams feature on the package's public API, and the library does not document per-request credential isolation as a caller responsibility. Validation confirmed that the library's own documented teardown, executorSymbol.dispose (bound to cleanupWebsocket), does not clear the state either — no supported API resets it.

Data flow

1. Source — a victim ExecutionRequest supplies extensions.connectionParams, commonly an Authorization or tenant token (packages/executors/legacy-ws/src/index.ts:90-100). 2. Propagation — Object.assign stores those keys in the closure-scoped executorConnectionParams (index.ts:32, index.ts:97-100). 3. Propagation — cleanupWebsocket terminates the socket but does not reset executorConnectionParams (index.ts:78-88); the attacker's later request omits connectionParams, so the conditional update is skipped and the old keys remain (index.ts:93-101). 4. Sink — the new socket's onopen merges the retained state into the connectioninit payload and sends it before the attacker's operation: payload = Object.assign(payload, executorConnectionParams); websocket!.send(...connectioninit payload...) (index.ts:44-65, index.ts:120-129). 5. Reachability — the package directly exports buildWSLegacyExecutor (index.ts:27), and the entry point is the returned disposable executor plus request.extensions.connectionParams.

Vulnerable code

packages/executors/legacy-ws/src/index.ts, lines 32-100 at b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1:

ts let executorConnectionParams = {}; // closure state, lives as long as the executor ... payload = Object.assign(payload, executorConnectionParams); // sink: retained keys enter connectioninit websocket!.send(JSON.stringify({ type: LEGACYWS.CONNECTIONINIT, payload })); ... if (request.extensions?.['connectionParams'] && typeof request.extensions?.['connectionParams'] === 'object') { executorConnectionParams = Object.assign(executorConnectionParams, request.extensions['connectionParams']); } // no else branch: a request that omits connectionParams leaves the previous request's keys in place, // and cleanupWebsocket (lines 78-88) resets only websocket, never executorConnectionParams.

Reproduction

Verified dynamically against the real package, not by static review alone. Release 1.1.33 was installed from npm and driven against a live local ws server implementing the legacy graphql-ws protocol, recording the connectioninit payload of every physical connection.

Observed across the validation runs, using one shared executor instance:

- Request 1 (victim) sent extensions.connectionParams {Authorization: "Bearer VICTIM-ALICE-SECRET-TOKEN", tenant: "alice-tenant"}. After that subscription completed, the executor sent connectionterminate and tore the socket down. - Request 2 supplied no connectionParams, yet the server observed a brand-new connection whose connectioninit payload was byte-identical to the victim's, including the victim's bearer token. - Partial overwrite: when the later caller supplied only {tenant: "mallory-tenant"}, Object.assign merged rather than replaced, so the victim's Authorization was retained alongside the attacker's tenant. - Calling executorSymbol.dispose between requests did not clear the state. - Negative control: patching the installed 1.1.33 to add executorConnectionParams = {} inside cleanupWebsocket made the second connection's init payload {}, so the leak is causally attributable to the missing reset. - The same proof of concept leaks identically on 1.1.35, and master still contains the unreset closure. Upstream line numbers on master are slightly offset from those cited above (closure declaration L38, conditional Object.assign L100-105, cleanupWebsocket L84, onopen merge L60-63).

Steps:

1. Run a legacy GraphQL WS server that records the connectioninit payload and authorizes notifications by Authorization value. 2. Use one executor for a victim request with extensions.connectionParams.Authorization = 'Bearer victim-token'; finish/unsubscribe it so cleanup closes the socket. 3. Use the same executor for an anonymous request with no extensions.connectionParams. 4. Observe that the second connectioninit still contains Bearer victim-token and that the anonymous subscription receives victim-authorized results.

ts import WebSocket from 'isomorphic-ws'; import { parse } from 'graphql'; import { buildWSLegacyExecutor } from '@graphql-tools/executor-legacy-ws';

const exec = buildWSLegacyExecutor('ws://localhost:8081/graphql', WebSocket);

const v = exec({ document: parse('subscription { privateEvents { id } }'), extensions: { connectionParams: { Authorization: 'Bearer victim-token' } }, }); await v.next(); await v.return?.(); // iterator cleanup closes the first socket; executorConnectionParams is not cleared

const a = exec({ document: parse('subscription { privateEvents { id } }') }); await a.next(); // the server logs a second connectioninit payload still carrying Authorization: Bearer victim-token

- Expected: the second connectioninit payload contains only the base options and the second request's own parameters, with no key omitted by that request carried over from the victim. - Actual: the second connectioninit carries the victim's retained Authorization/tenant values, and the downstream server authenticates the later caller as the victim.

Suggested remediation

Do not retain operation-level extensions.connectionParams beyond the operation and connection they belong to: build each connectioninit payload from the base options plus the current request's parameters only, and clear connection-specific state during cleanup.

ts // 1. Clear the retained state when the connection is torn down. function cleanupWebsocket() { // ... existing connectionterminate / terminate / websocket = null ... executorConnectionParams = {}; }

Alternative, and the more complete form: stop accumulating into shared closure state at all — derive the payload per request, e.g. const payload = { ...options.connectionParams, ...(request.extensions?.['connectionParams'] ?? {}) }, passed down to the onopen handler for that connection rather than read from the executor closure.

Notes:

- executorSymbol.dispose is bound to cleanupWebsocket, so fixing the reset there also makes the documented teardown behave as callers expect. - Object.assign merges rather than replaces, so a later request supplying some parameters still inherits the keys it omits; a fix that only replaces the object on the "present" branch would leave this half-open. - The UrlLoader path in this repository constructs a fresh executor per request and is already safe; no change is needed there. The exposure is to direct consumers of the exported buildWSLegacyExecutor. - It would also help to define explicitly whether mixing distinct auth contexts on one executor is supported, and to reject it if not — today the factory returns a general Executor with no stated per-identity restriction.

Suggested regression test alongside the package's existing legacy-ws executor tests (the exact spec file is not verified by the available information): drive one executor against a recording server, send request 1 with extensions.connectionParams = { Authorization: 'Bearer victim-token' }, let it complete, then send request 2 with no connectionParams, and assert the second connectioninit payload contains no Authorization key. Add a second case where request 2 supplies only { tenant: 'other' } and assert the payload is exactly { tenant: 'other' } plus base options.

Disclosure status

Currently undisclosed: no CVE, GHSA, advisory URL, upstream issue, or fix commit is associated with this finding, and no fixed version exists to upgrade to.

Resolution

Fixed in @graphql-tools/executor-legacy-ws@1.1.36, which is published on npm. Each execution opens its own socket. connectioninit is built from the executor's base connectionParams plus only that request's extensions.connectionParams. Upgrade to >=1.1.36.

1 / 2
Source: GitHub
First published (updated )
Severity
7.4
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Impact

buildWSLegacyExecutor() in @graphql-tools/executor-legacy-ws previously hardcoded rejectUnauthorized: false when creating WebSocket connections over WSS. This disabled TLS certificate validation, allowing a network-positioned attacker to perform a Man-in-the-Middle (MITM) attack against applications that use this executor with a wss:// endpoint. Credentials passed via connectionParams or headers could be intercepted, and subscription data could be tampered with.

Who is impacted: Applications using @graphql-tools/executor-legacy-ws (directly or via @graphql-tools/url-loader with SubscriptionProtocol.LEGACYWS) to connect to a wss:// endpoint from Node.js while sending authentication material over the connection.

Browser WebSocket clients are unaffected by this option (browsers always validate certificates).

Patches

Upgrade to @graphql-tools/executor-legacy-ws@1.1.35 or later (and @graphql-tools/url-loader@9.1.9 or later if you use the loader). TLS certificate validation is enabled by default. Callers that intentionally use self-signed certificates in trusted environments can opt out with rejectUnauthorized: false.

Workarounds

- Prefer the modern graphql-ws / SubscriptionProtocol.WS path where possible. - Until upgraded, avoid sending secrets over legacy WSS connections, or terminate TLS at a trusted proxy and use ws:// only on trusted networks. - Supply a custom webSocketImpl that enforces certificate validation.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203