diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..d300267 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.gitignore b/.gitignore index 1df9a3d..bbcb0c6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,4 @@ build/Release # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git node_modules -test* +# test* - removed to allow test suite in test/ directory diff --git a/CHANGELOG.md b/CHANGELOG.md index 857e44a..fb25885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,37 @@ +Version: 0.4.0 +------------ +### Security Hardening +- Add input validation for all user-provided addresses in stringToS7Addr (DB number, offset, array length, bit offset) +- Add safeParseInt helper to prevent NaN/out-of-range values from silently becoming 0 +- Add buffer bounds checking to prevent overflow in read/write operations +- Fix TPKT/S7 packet length validation in checkRFCData (minimum length, TPKT range validation) +- Fix logic bug in checkRFCData: changed && to || for RFC version/TPDU code check +- Complete S7 header validation in onResponse (replaces TODO at former line 1196) +- Decompose monolithic validation condition into labeled individual checks with clear error messages +- Add bounds checking in sendWritePacket to prevent data buffer overflow +- Add reportedDataLength validation in processS7Packet +- Add buffer size limit check during read optimization (prepareReadPacket) +- Add string type validation and buffer bounds checking in bufferizeS7Item +- Add security section to README with CVE references and best practices + +### Connection Hardening +- Add configurable exponential backoff for reconnection (reconnectDelay, maxReconnectDelay options) +- Add maximum reconnection attempts limit (maxReconnectAttempts option) +- Track reconnect count, reset on successful connection + +### New Features +- Add EventEmitter inheritance - emit 'connecting', 'connected', 'disconnected', 'reconnecting', 'connect-failed', 'error' events +- Add optional TLS support for S7-1500 (FW 2.0+) and S7-1200 (FW 4.3+) via { tls: true } option +- Add Promise/async-await API: initiateConnectionAsync, readAllItemsAsync, writeItemsAsync, dropConnectionAsync +- Add structured error codes (NodeS7.errors) for programmatic error handling + +### Modernization +- Update Node.js engine requirement from "node 10.x.x" to ">=14.0.0" +- Add TypeScript type definitions (nodeS7.d.ts) +- Add test suite using Node.js built-in test runner (59 tests covering address parsing, packet validation, buffer helpers) +- Update package.json with proper engines field, types, scripts, files, and expanded keywords +- Export internal functions for testing when NODE_ENV=test + Version: 0.3.18 ------------ - Add support for WDT to specify date/time which can be either UTC or local time depending on a connection parameter diff --git a/README.md b/README.md index b73e5bb..a131fd7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,25 @@ WARNING ======= Fully test everything you do. In situations where writing to a random area of memory within the PLC could cost you money, back up your data and test this really well. If this could injure someone or worse, consider other software. +Security +======= + +**Important:** The S7comm protocol used by this library is inherently insecure. It was designed for isolated industrial networks and provides no built-in encryption or authentication. Be aware of the following: + +* **Network Isolation**: Always use this library on isolated industrial networks or behind firewalls. Never expose S7 communication (port 102) to untrusted networks or the internet. +* **TLS Support (v0.4.0+)**: Optional TLS encryption is available for S7-1500 (FW 2.0+) and S7-1200 (FW 4.3+). Enable with `{ tls: true }` in connection parameters. +* **GET/PUT Access**: Enabling GET/PUT on S7-1200/1500 opens the controller to all applications on the network, not just this library. +* **Known CVEs**: Multiple vulnerabilities affect the S7 protocol ecosystem: + * CVE-2022-38465 (CVSS 9.3) - Private key extraction in S7-1200/1500 + * CVE-2022-38773 - Firmware decryption vulnerabilities in S7-1500 + * CVE-2021-37205 / CVE-2021-37185 - DoS via crafted packets on port 102 +* **Recommendations**: + * Use VPN or network segmentation to protect PLC traffic + * Keep PLC firmware updated to the latest version + * Consider using TLS connections where supported + * Monitor network traffic for unauthorized access to port 102 + * For new installations, consider OPC UA as a more secure alternative + Installation ======= Using npm: @@ -109,6 +128,27 @@ API - [writeItems()](#write-items) - [readAllItems()](#read-all-items) +### Promise/Async API (v0.4.0+) + - `initiateConnectionAsync(options)` - Returns `Promise` + - `readAllItemsAsync()` - Returns `Promise>` + - `writeItemsAsync(items, values)` - Returns `Promise` + - `dropConnectionAsync()` - Returns `Promise` + +### Events (v0.4.0+) +NodeS7 extends EventEmitter and emits the following events: + - `'connecting'` - Connection attempt started + - `'connected'` - Successfully connected and ready + - `'disconnected'` - Connection lost or closed + - `'reconnecting'` - Reconnection attempt (emits `{ attempt, delay }`) + - `'connect-failed'` - Max reconnection attempts exhausted (emits `{ attempts }`) + - `'error'` - Connection error occurred + +### Error Codes (v0.4.0+) +Access via `NodeS7.errors`: + - `ERR_S7_TIMEOUT`, `ERR_S7_INVALID_ADDRESS`, `ERR_S7_BUFFER_OVERFLOW` + - `ERR_S7_PACKET_MALFORMED`, `ERR_S7_CONNECTION_REFUSED`, `ERR_S7_NOT_CONNECTED` + - `ERR_S7_WRITE_IN_PROGRESS`, `ERR_S7_PLC_ERROR`, `ERR_S7_INVALID_ARGUMENT` + ## nodes7.initiateConnection(options, callback) #### Description @@ -118,15 +158,20 @@ Connects to a PLC. `Options` -|Property|type|default| -| --- | --- | --- | -| rack | number | 0 | -| slot | number | 2 | -| port | number | 102 | -| host | string | 192.168.8.106 | -| timeout | number | 5000 | -| localTSAP | hex | undefined | -| remoteTSAP | hex | undefined | +|Property|type|default|description| +| --- | --- | --- | --- | +| rack | number | 0 | PLC rack number | +| slot | number | 2 | PLC slot (2 for S7-300/400, 1 for 1200/1500) | +| port | number | 102 | TCP port | +| host | string | 192.168.8.106 | PLC IP address | +| timeout | number | 5000 | TCP connection timeout (ms) | +| localTSAP | hex | undefined | Local TSAP for direct TSAP mode | +| remoteTSAP | hex | undefined | Remote TSAP for direct TSAP mode | +| tls | boolean | false | Enable TLS encryption (S7-1500 FW 2.0+) | +| tlsOptions | object | undefined | Options passed to tls.connect() (ca, cert, key, rejectUnauthorized) | +| maxReconnectAttempts | number | Infinity | Maximum reconnection attempts | +| reconnectDelay | number | 2000 | Base reconnection delay (ms) | +| maxReconnectDelay | number | 30000 | Maximum reconnection delay for exponential backoff (ms) | `callback(err)`
diff --git a/nodeS7.d.ts b/nodeS7.d.ts new file mode 100644 index 0000000..2fd665a --- /dev/null +++ b/nodeS7.d.ts @@ -0,0 +1,118 @@ +import { EventEmitter } from 'events'; + +declare class NodeS7 extends EventEmitter { + constructor(opts?: { silent?: boolean; debug?: boolean }); + + /** + * Initiate a connection to a Siemens S7 PLC. + */ + initiateConnection(params: NodeS7.ConnectionParams, callback: (err?: Error) => void): void; + + /** + * Drop the connection to the PLC. + */ + dropConnection(callback: () => void): void; + + /** + * Set a translation callback for mapping symbolic names to S7 addresses. + */ + setTranslationCB(translator: (tag: string) => string): void; + + /** + * Add items (S7 addresses) to the read polling list. + */ + addItems(items: string | string[]): void; + + /** + * Remove items from the read polling list. If no items specified, removes all. + */ + removeItems(items?: string | string[]): void; + + /** + * Read all items currently in the polling list. + */ + readAllItems(callback: (err: boolean, values: Record) => void): void; + + /** + * Write values to specified S7 addresses. + */ + writeItems(items: string | string[], values: any | any[], callback: (err: boolean) => void): number; + + // Promise-based API + + /** + * Promise-based version of initiateConnection. + */ + initiateConnectionAsync(params: NodeS7.ConnectionParams): Promise; + + /** + * Promise-based version of readAllItems. + */ + readAllItemsAsync(): Promise>; + + /** + * Promise-based version of writeItems. + */ + writeItemsAsync(items: string | string[], values: any | any[]): Promise; + + /** + * Promise-based version of dropConnection. + */ + dropConnectionAsync(): Promise; + + // Events + on(event: 'connecting', listener: () => void): this; + on(event: 'connected', listener: () => void): this; + on(event: 'disconnected', listener: () => void): this; + on(event: 'reconnecting', listener: (info: { attempt: number; delay: number }) => void): this; + on(event: 'connect-failed', listener: (info: { attempts: number }) => void): this; + on(event: 'error', listener: (err: Error) => void): this; + + // Static properties + static errors: NodeS7.S7ErrorCodes; +} + +declare namespace NodeS7 { + interface ConnectionParams { + host?: string; + port?: number; + rack?: number; + slot?: number; + timeout?: number; + localTSAP?: number; + remoteTSAP?: number; + connection_name?: string; + doNotOptimize?: boolean; + wdtAsUTC?: boolean; + /** Enable TLS encryption (requires S7-1500 FW 2.0+ or S7-1200 FW 4.3+) */ + tls?: boolean; + /** TLS options passed to tls.connect() */ + tlsOptions?: { + ca?: Buffer | string; + cert?: Buffer | string; + key?: Buffer | string; + rejectUnauthorized?: boolean; + minVersion?: string; + }; + /** Maximum number of reconnection attempts (default: Infinity) */ + maxReconnectAttempts?: number; + /** Base reconnection delay in ms (default: 2000) */ + reconnectDelay?: number; + /** Maximum reconnection delay in ms for exponential backoff (default: 30000) */ + maxReconnectDelay?: number; + } + + interface S7ErrorCodes { + TIMEOUT: string; + INVALID_ADDRESS: string; + BUFFER_OVERFLOW: string; + PACKET_MALFORMED: string; + CONNECTION_REFUSED: string; + NOT_CONNECTED: string; + WRITE_IN_PROGRESS: string; + PLC_ERROR: string; + INVALID_ARGUMENT: string; + } +} + +export = NodeS7; diff --git a/nodeS7.js b/nodeS7.js index dbf0346..a7ac2a4 100644 --- a/nodeS7.js +++ b/nodeS7.js @@ -33,16 +33,46 @@ var net = require("net"); var util = require("util"); +var EventEmitter = require("events"); var effectiveDebugLevel = 0; // intentionally global, shared between connections var silentMode = false; module.exports = NodeS7; +// Error code constants for structured error handling +var S7Error = { + TIMEOUT: 'ERR_S7_TIMEOUT', + INVALID_ADDRESS: 'ERR_S7_INVALID_ADDRESS', + BUFFER_OVERFLOW: 'ERR_S7_BUFFER_OVERFLOW', + PACKET_MALFORMED: 'ERR_S7_PACKET_MALFORMED', + CONNECTION_REFUSED: 'ERR_S7_CONNECTION_REFUSED', + NOT_CONNECTED: 'ERR_S7_NOT_CONNECTED', + WRITE_IN_PROGRESS: 'ERR_S7_WRITE_IN_PROGRESS', + PLC_ERROR: 'ERR_S7_PLC_ERROR', + INVALID_ARGUMENT: 'ERR_S7_INVALID_ARGUMENT' +}; + +NodeS7.errors = S7Error; + +// Maximum allowed byte length for a single S7 item request +var MAX_BYTE_LENGTH = 8192; + +// Input validation helper - returns undefined if value is NaN or out of range +function safeParseInt(str, min, max) { + var val = parseInt(str, 10); + if (isNaN(val) || val < min || val > max) { + return undefined; + } + return val; +} + function NodeS7(opts) { opts = opts || {}; silentMode = opts.silent || false; effectiveDebugLevel = opts.debug ? 99 : 0 + EventEmitter.call(this); + var self = this; self.connectReq = Buffer.from([0x03, 0x00, 0x00, 0x16, 0x11, 0xe0, 0x00, 0x00, 0x00, 0x02, 0x00, 0xc0, 0x01, 0x0a, 0xc1, 0x02, 0x01, 0x00, 0xc2, 0x02, 0x01, 0x02]); @@ -93,8 +123,16 @@ function NodeS7(opts) { self.dropConnectionTimer = null; self.reconnectTimer = undefined; self.rereadTimer = undefined; + + // Connection hardening properties + self.reconnectCount = 0; + self.maxReconnectAttempts = Infinity; + self.baseReconnectDelay = 2000; + self.maxReconnectDelay = 30000; } +util.inherits(NodeS7, EventEmitter); + NodeS7.prototype.getNextSeqNum = function() { var self = this; @@ -142,9 +180,19 @@ NodeS7.prototype.initiateConnection = function(cParam, callback) { if (typeof (cParam.timeout) !== 'undefined') { // Added in 0.3.17 to ensure packets don't timeout at 1500ms if the user has specified a timeout externally. self.globalTimeout = cParam.timeout; } + if (typeof (cParam.maxReconnectAttempts) !== 'undefined') { + self.maxReconnectAttempts = cParam.maxReconnectAttempts; + } + if (typeof (cParam.reconnectDelay) !== 'undefined') { + self.baseReconnectDelay = cParam.reconnectDelay; + } + if (typeof (cParam.maxReconnectDelay) !== 'undefined') { + self.maxReconnectDelay = cParam.maxReconnectDelay; + } self.connectionParams = cParam; self.connectCallback = callback; self.connectCBIssued = false; + self.reconnectCount = 0; self.connectNow(self.connectionParams, false); } @@ -195,19 +243,38 @@ NodeS7.prototype.connectNow = function(cParam) { if (self.isoConnectionState >= 1) { return; } self.connectionCleanup(); - self.isoclient = net.connect(cParam); + // Support optional TLS connections (S7-1500 FW 2.0+, S7-1200 FW 4.3+) + if (cParam.tls) { + var tls = require('tls'); + var tlsOpts = Object.assign({}, cParam, { + minVersion: 'TLSv1.2', + rejectUnauthorized: cParam.tlsOptions && cParam.tlsOptions.rejectUnauthorized !== undefined ? cParam.tlsOptions.rejectUnauthorized : true + }, cParam.tlsOptions || {}); + self.isoclient = tls.connect(tlsOpts); + } else { + self.isoclient = net.connect(cParam); + } + + self.isoclient.setTimeout(cParam.timeout || 5000, () => { + self.isoclient.destroy(); + self.connectError.apply(self, [{ code: 'EUSERTIMEOUT' }]); + }); - self.isoclient.setTimeout(cParam.timeout || 5000, () => { - self.isoclient.destroy(); - self.connectError.apply(self, [{ code: 'EUSERTIMEOUT' }]); // Former use of "arguments" was always going to be 0. Use "USERTIMEOUT" to show difference between this and TCP timeout. - }); + self.isoclient.once('connect', () => { + self.isoclient.setTimeout(0); + self.onTCPConnect.apply(self, arguments); + }); - self.isoclient.once('connect', () => { - self.isoclient.setTimeout(0); - self.onTCPConnect.apply(self, arguments); - }); + // TLS sockets emit 'secureConnect' instead of 'connect' + if (cParam.tls) { + self.isoclient.once('secureConnect', () => { + self.isoclient.setTimeout(0); + self.onTCPConnect.apply(self, arguments); + }); + } - self.isoConnectionState = 1; // 1 = trying to connect + self.isoConnectionState = 1; // 1 = trying to connect + self.emit('connecting'); self.isoclient.on('error', function() { self.connectError.apply(self, arguments); @@ -227,6 +294,7 @@ NodeS7.prototype.connectError = function(e) { self.connectCallback(e); } self.isoConnectionState = 0; + self.emit('error', e); } NodeS7.prototype.readWriteError = function(e) { @@ -236,33 +304,60 @@ NodeS7.prototype.readWriteError = function(e) { self.connectionReset(); } +NodeS7.prototype.getReconnectDelay = function() { + var self = this; + var delay = Math.min(self.baseReconnectDelay * Math.pow(2, self.reconnectCount), self.maxReconnectDelay); + // Add jitter (up to 10% of delay) + delay += Math.floor(Math.random() * delay * 0.1); + return delay; +} + NodeS7.prototype.packetTimeout = function(packetType, packetSeqNum) { var self = this; outputLog('PacketTimeout called with type ' + packetType + ' and seq ' + packetSeqNum, 1, self.connectionID); if (packetType === "connect") { outputLog("TIMED OUT connecting to the PLC - Disconnecting", 0, self.connectionID); - outputLog("Wait for 2 seconds then try again.", 0, self.connectionID); self.connectionReset(); - outputLog("Scheduling a reconnect from packetTimeout, connect type", 0, self.connectionID); + + // Check max reconnect attempts + if (self.reconnectCount >= self.maxReconnectAttempts) { + outputLog("Max reconnect attempts (" + self.maxReconnectAttempts + ") reached. Giving up.", 0, self.connectionID); + self.emit('connect-failed', { attempts: self.reconnectCount }); + return undefined; + } + + var reconnectDelay = self.getReconnectDelay(); + self.reconnectCount++; + outputLog("Scheduling reconnect attempt " + self.reconnectCount + " in " + reconnectDelay + "ms", 0, self.connectionID); + self.emit('reconnecting', { attempt: self.reconnectCount, delay: reconnectDelay }); clearTimeout(self.reconnectTimer); self.reconnectTimer = setTimeout(function() { outputLog("The scheduled reconnect from packetTimeout, connect type, is happening now", 0, self.connectionID); if (self.isoConnectionState === 0) { self.connectNow.apply(self, arguments); } - }, 2000, self.connectionParams); + }, reconnectDelay, self.connectionParams); return undefined; } if (packetType === "PDU") { - outputLog("TIMED OUT waiting for PDU reply packet from PLC - Disconnecting"); - outputLog("Wait for 2 seconds then try again.", 0, self.connectionID); + outputLog("TIMED OUT waiting for PDU reply packet from PLC - Disconnecting", 0, self.connectionID); self.connectionReset(); - outputLog("Scheduling a reconnect from packetTimeout, connect type", 0, self.connectionID); + + if (self.reconnectCount >= self.maxReconnectAttempts) { + outputLog("Max reconnect attempts (" + self.maxReconnectAttempts + ") reached. Giving up.", 0, self.connectionID); + self.emit('connect-failed', { attempts: self.reconnectCount }); + return undefined; + } + + var reconnectDelay = self.getReconnectDelay(); + self.reconnectCount++; + outputLog("Scheduling reconnect attempt " + self.reconnectCount + " in " + reconnectDelay + "ms", 0, self.connectionID); + self.emit('reconnecting', { attempt: self.reconnectCount, delay: reconnectDelay }); clearTimeout(self.reconnectTimer); self.reconnectTimer = setTimeout(function() { outputLog("The scheduled reconnect from packetTimeout, PDU type, is happening now", 0, self.connectionID); self.connectNow.apply(self, arguments); - }, 2000, self.connectionParams); + }, reconnectDelay, self.connectionParams); return undefined; } if (packetType === "read") { @@ -427,6 +522,10 @@ NodeS7.prototype.onPDUReply = function(theData) { self.readWriteError.apply(self, arguments); }); // Might want to remove the self.connecterror listener //self.isoclient.removeAllListeners('error'); + // Reset reconnect count on successful connection + self.reconnectCount = 0; + self.emit('connected'); + if ((!self.connectCBIssued) && (typeof (self.connectCallback) === "function")) { self.connectCBIssued = true; self.connectCallback(); @@ -847,6 +946,7 @@ NodeS7.prototype.prepareReadPacket = function() { (itemList[i].dbNumber !== self.globalReadBlockList[thisBlock].dbNumber) || // Can't optimize across DBs (!self.isOptimizableArea(itemList[i].areaS7Code)) || // Can't optimize T,C (I don't think) and definitely not P. ((itemList[i].offset - self.globalReadBlockList[thisBlock].offset + itemList[i].byteLength) > maxByteRequest) || // If this request puts us over our max byte length, create a new block for consistency reasons. + ((itemList[i].offset - self.globalReadBlockList[thisBlock].offset + itemList[i].byteLength) > MAX_BYTE_LENGTH) || // Buffer bounds: prevent merged block from exceeding buffer size. (itemList[i].offset - (self.globalReadBlockList[thisBlock].offset + self.globalReadBlockList[thisBlock].byteLength) > self.maxGap)) { // If our gap is large, create a new block. outputLog("Skipping optimization of item " + itemList[i].addr, 0, self.connectionID); @@ -1068,6 +1168,11 @@ NodeS7.prototype.sendWritePacket = function() { for (var j = 0; j < self.writePacketArray[i].itemList.length; j++) { S7AddrToBuffer(self.writePacketArray[i].itemList[j], true).copy(self.writeReq, 19 + j * 12); itemBuffer = getWriteBuffer(self.writePacketArray[i].itemList[j]); + // Buffer bounds check to prevent overflow + if (dataBufferPointer + itemBuffer.length > 8192) { + outputLog('Write buffer overflow prevented - data exceeds 8192 bytes at item ' + j, 0, self.connectionID); + break; + } itemBuffer.copy(dataBuffer, dataBufferPointer); dataBufferPointer += itemBuffer.length; // NOTE: It seems that when writing, the data that is sent must have a "fill byte" so that data length is even only for all @@ -1192,29 +1297,72 @@ NodeS7.prototype.onResponse = function(theData) { }); }else if( data[7] === 0x32 ){//check the validy of FA+S7 package - //********************* VALIDY CHECK *********************************** - //TODO: Check S7-Header properly - if (data.length > 8 && data[8] != 3) { - outputLog('PDU type (byte 8) was returned as ' + data[8] + ' where the response PDU of 3 was expected.'); + //********************* VALIDITY CHECK *********************************** + // S7 Header validation (replaces former TODO) + + // Validate minimum length for S7 header (17 bytes: 4 TPKT + 3 COTP + 10 S7 header minimum) + if (data.length < 17) { + outputLog('S7 packet too short for header validation: ' + data.length + ' bytes'); + self.connectionReset(); + return null; + } + + // Validate S7 protocol ID (byte 7 must be 0x32) + if (data[7] !== 0x32) { + outputLog('Invalid S7 protocol ID: 0x' + data[7].toString(16) + ' (expected 0x32)'); + self.connectionReset(); + return null; + } + + // Validate PDU type (byte 8): 1=request, 2=ack, 3=ack-data, 7=userdata + var pduType = data[8]; + if (pduType !== 1 && pduType !== 2 && pduType !== 3 && pduType !== 7) { + outputLog('Invalid S7 PDU type: ' + pduType + ' (expected 1, 2, 3, or 7)'); + outputLog(data); + self.connectionReset(); + return null; + } + + // For read/write responses we expect ack-data (type 3) + if (pduType !== 3) { + outputLog('PDU type (byte 8) was returned as ' + pduType + ' where the response PDU of 3 was expected.'); outputLog('Maybe you are requesting more than 240 bytes of data in a packet?'); outputLog(data); self.connectionReset(); return null; } - // The smallest read packet will pass a length check of 25. For a 1-item write response with no data, length will be 22. - if (data.length > data.readInt16BE(2)) { - outputLog("An oversize packet was detected. Excess length is " + (data.length - data.readInt16BE(2)) + ". "); + + // Check for oversize packet (two packets concatenated) + var tpktLength = data.readInt16BE(2); + if (data.length > tpktLength) { + outputLog("An oversize packet was detected. Excess length is " + (data.length - tpktLength) + ". "); outputLog("We assume this is because two packets were sent at nearly the same time by the PLC."); outputLog("We are slicing the buffer and scheduling the second half for further processing next loop."); setTimeout(function() { self.onResponse.apply(self, arguments); - }, 0, data.slice(data.readInt16BE(2))); // This re-triggers this same function with the sliced-up buffer. - // was used as a test setTimeout(process.exit, 2000); - } - - if (data.length < data.readInt16BE(2) || data.readInt16BE(2) < 22 || data[5] !== 0xf0 || data[4] + 1 + 12 + 4 + data.readInt16BE(13) + data.readInt16BE(15) !== data.readInt16BE(2) || !(data[6] >> 7) || (data[7] !== 0x32) || (data[8] !== 3)) { + }, 0, data.slice(tpktLength)); + } + + // Validate COTP data indicator + var cotpValid = data[5] === 0xf0; + // Validate last data unit flag + var lastDataUnitValid = !!(data[6] >> 7); + // Validate parameter length and data length consistency + var cotpLength = data[4]; + var paramLength = data.readInt16BE(13); + var dataLength = data.readInt16BE(15); + var expectedTpktLength = cotpLength + 1 + 12 + 4 + paramLength + dataLength; + var lengthConsistent = expectedTpktLength === tpktLength; + + // Validate received data is at least as long as TPKT declares + var bufferSufficient = data.length >= tpktLength; + // Validate minimum TPKT length for a valid S7 response + var tpktMinValid = tpktLength >= 22; + + if (!bufferSufficient || !tpktMinValid || !cotpValid || !lengthConsistent || !lastDataUnitValid) { outputLog('INVALID READ RESPONSE - DISCONNECTING'); - outputLog('TPKT Length From Header is ' + data.readInt16BE(2) + ' and RCV buffer length is ' + data.length + ' and COTP length is ' + data.readUInt8(4) + ' and data[6] is ' + data[6]); + outputLog('Validation details: bufferOK=' + bufferSufficient + ' tpktMinOK=' + tpktMinValid + ' cotpOK=' + cotpValid + ' lengthOK=' + lengthConsistent + ' lastUnitOK=' + lastDataUnitValid); + outputLog('TPKT Length=' + tpktLength + ' RCV length=' + data.length + ' COTP length=' + cotpLength + ' paramLength=' + paramLength + ' dataLength=' + dataLength); outputLog(data); self.connectionReset(); return null; @@ -1538,8 +1686,12 @@ NodeS7.prototype.resetNow = function() { NodeS7.prototype.connectionCleanup = function() { var self = this; + var wasConnected = self.isoConnectionState >= 4; self.isoConnectionState = 0; outputLog('Connection cleanup is happening', 0, self.connectionID); + if (wasConnected) { + self.emit('disconnected'); + } if (typeof (self.isoclient) !== "undefined") { // destroy the socket connection self.isoclient.destroy(); @@ -1564,12 +1716,26 @@ NodeS7.prototype.connectionCleanup = function() { function checkRFCData(data){ var ret=null; + + // Validate minimum buffer length before accessing fields + if (!Buffer.isBuffer(data) || data.length < 7) { + outputLog('checkRFCData: Packet too short (' + (data ? data.length : 0) + ' bytes, minimum 7)'); + return 'error'; + } + var RFC_Version = data[0]; var TPKT_Length = data.readInt16BE(2); var TPDU_Code = data[5]; //Data==0xF0 !! var LastDataUnit = data[6];//empty fragmented frame => 0=not the last package; 1=last package - if(RFC_Version !==0x03 && TPDU_Code !== 0xf0){ + // Validate TPKT length range + if (TPKT_Length < 7 || TPKT_Length > 65535) { + outputLog('checkRFCData: Invalid TPKT length: ' + TPKT_Length); + return 'error'; + } + + // Fix: Use OR (||) instead of AND (&&) - either condition being wrong should be an error + if(RFC_Version !==0x03 || TPDU_Code !== 0xf0){ //Check if its an RFC package and a Data package return 'error'; }else if((LastDataUnit >> 7) === 0 && TPKT_Length == data.length && data.length === 7){ @@ -1666,6 +1832,15 @@ function processS7Packet(theData, theItem, thePointer, theCID) { } else { reportedDataLength = theData.readUInt16BE(thePointer + 2); } + + // Validate reported data length is non-negative and within bounds + if (reportedDataLength < 0 || reportedDataLength > MAX_BYTE_LENGTH) { + theItem.valid = false; + theItem.errCode = 'Reported data length out of bounds: ' + reportedDataLength; + outputLog(theItem.errCode, 0, theCID); + return 0; + } + var responseCode = theData[thePointer]; var transportCode = theData[thePointer + 1]; @@ -2198,6 +2373,16 @@ function bufferizeS7Item(theItem) { break; case "S": case "STRING": + // Validate write value is a string + if (typeof theItem.writeValue[arrayIndex] !== 'string') { + outputLog("Warning - Non-string value provided for STRING write, converting to string"); + theItem.writeValue[arrayIndex] = String(theItem.writeValue[arrayIndex]); + } + // Bounds check for write pointer + if (thePointer + theItem.dtypelen > theItem.writeBuffer.length) { + outputLog("Error - String write would exceed buffer bounds"); + return 0; + } // Convert to bytes. theItem.writeBuffer.writeUInt8(theItem.dtypelen - 2, thePointer); // Array length is requested val, -2 is string length theItem.writeBuffer.writeUInt8(Math.min(theItem.dtypelen - 2, theItem.writeValue[arrayIndex].length), thePointer+1); // Array length is requested val, -2 is string length @@ -2287,6 +2472,16 @@ function bufferizeS7Item(theItem) { break; case "S": case "STRING": + // Validate write value is a string + if (typeof theItem.writeValue !== 'string') { + outputLog("Warning - Non-string value provided for STRING write, converting to string"); + theItem.writeValue = String(theItem.writeValue); + } + // Bounds check for write pointer + if (thePointer + theItem.dtypelen > theItem.writeBuffer.length) { + outputLog("Error - String write would exceed buffer bounds"); + return 0; + } // Convert to bytes. theItem.writeBuffer.writeUInt8(theItem.dtypelen - 2, thePointer); // Array length is requested val, -2 is string length theItem.writeBuffer.writeUInt8(Math.min(theItem.dtypelen - 2, theItem.writeValue.length), thePointer+1); // Array length is requested val, -2 is string length @@ -2318,6 +2513,12 @@ function stringToS7Addr(addr, useraddr, cParam) { if (useraddr === '_COMMERR') { return undefined; } // Special-case for communication error status - this variable returns true when there is a communications error + // Validate input is a non-empty string + if (typeof addr !== 'string' || addr.length === 0) { + outputLog("Error - Invalid address: must be a non-empty string"); + return undefined; + } + theItem = new S7Item(); splitString = addr.split(','); if (splitString.length === 0 || splitString.length > 2) { @@ -2330,15 +2531,37 @@ function stringToS7Addr(addr, useraddr, cParam) { splitString2 = splitString[1].split('.'); theItem.datatype = splitString2[0].replace(/[0-9]/gi, '').toUpperCase(); // Clear the numbers if (theItem.datatype === 'X' && splitString2.length === 3) { - theItem.arrayLength = parseInt(splitString2[2], 10); + theItem.arrayLength = safeParseInt(splitString2[2], 1, MAX_BYTE_LENGTH); + if (theItem.arrayLength === undefined) { + outputLog("Error - Invalid array length in address " + addr); + return undefined; + } } else if ((theItem.datatype === 'S' || theItem.datatype === 'STRING') && splitString2.length === 3) { - theItem.dtypelen = parseInt(splitString2[1], 10) + 2; // With strings, add 2 to the length due to S7 header - theItem.arrayLength = parseInt(splitString2[2], 10); // For strings, array length is now the number of strings + var strLen = safeParseInt(splitString2[1], 1, MAX_BYTE_LENGTH - 2); + if (strLen === undefined) { + outputLog("Error - Invalid string length in address " + addr); + return undefined; + } + theItem.dtypelen = strLen + 2; // With strings, add 2 to the length due to S7 header + theItem.arrayLength = safeParseInt(splitString2[2], 1, MAX_BYTE_LENGTH); + if (theItem.arrayLength === undefined) { + outputLog("Error - Invalid array length in address " + addr); + return undefined; + } } else if ((theItem.datatype === 'S' || theItem.datatype === 'STRING') && splitString2.length === 2) { - theItem.dtypelen = parseInt(splitString2[1], 10) + 2; // With strings, add 2 to the length due to S7 header + var strLen2 = safeParseInt(splitString2[1], 1, MAX_BYTE_LENGTH - 2); + if (strLen2 === undefined) { + outputLog("Error - Invalid string length in address " + addr); + return undefined; + } + theItem.dtypelen = strLen2 + 2; // With strings, add 2 to the length due to S7 header theItem.arrayLength = 1; } else if (theItem.datatype !== 'X' && splitString2.length === 2) { - theItem.arrayLength = parseInt(splitString2[1], 10); + theItem.arrayLength = safeParseInt(splitString2[1], 1, MAX_BYTE_LENGTH); + if (theItem.arrayLength === undefined) { + outputLog("Error - Invalid array length in address " + addr); + return undefined; + } } else { theItem.arrayLength = 1; } @@ -2347,18 +2570,24 @@ function stringToS7Addr(addr, useraddr, cParam) { return undefined; } - // Get the data block number from the first part. - theItem.dbNumber = parseInt(splitString[0].replace(/[A-z]/gi, ''), 10); + // Get the data block number from the first part with validation. + theItem.dbNumber = safeParseInt(splitString[0].replace(/[A-z]/gi, ''), 1, 65535); + if (theItem.dbNumber === undefined) { + outputLog("Error - Invalid DB number in address " + addr); + return undefined; + } // Get the data block byte offset from the second part, eliminating characters. - // Note that at this point, we may miss some info, like a "T" at the end indicating TIME data type or DATE data type or DT data type. We ignore these. - // This is on the TODO list. - theItem.offset = parseInt(splitString2[0].replace(/[A-z]/gi, ''), 10); // Get rid of characters + theItem.offset = safeParseInt(splitString2[0].replace(/[A-z]/gi, ''), 0, 65535); + if (theItem.offset === undefined) { + outputLog("Error - Invalid byte offset in address " + addr); + return undefined; + } // Get the bit offset if (splitString2.length > 1 && theItem.datatype === 'X') { - theItem.bitOffset = parseInt(splitString2[1], 10); - if (theItem.bitOffset > 7) { + theItem.bitOffset = safeParseInt(splitString2[1], 0, 7); + if (theItem.bitOffset === undefined) { outputLog("Invalid bit offset specified for address " + addr); return undefined; } @@ -2588,19 +2817,35 @@ function stringToS7Addr(addr, useraddr, cParam) { theItem.bitOffset = 0; if (splitString2.length > 1 && theItem.datatype === 'X') { // Bit and bit array - theItem.bitOffset = parseInt(splitString2[1].replace(/[A-z]/gi, ''), 10); + theItem.bitOffset = safeParseInt(splitString2[1].replace(/[A-z]/gi, ''), 0, 7); + if (theItem.bitOffset === undefined) { + outputLog("Error - Invalid bit offset in address " + addr); + return undefined; + } if (splitString2.length > 2) { // Bit array only - theItem.arrayLength = parseInt(splitString2[2].replace(/[A-z]/gi, ''), 10); + theItem.arrayLength = safeParseInt(splitString2[2].replace(/[A-z]/gi, ''), 1, MAX_BYTE_LENGTH); + if (theItem.arrayLength === undefined) { + outputLog("Error - Invalid array length in address " + addr); + return undefined; + } } else { theItem.arrayLength = 1; } - } else if (splitString2.length > 1 && theItem.datatype !== 'X') { // Bit and bit array - theItem.arrayLength = parseInt(splitString2[1].replace(/[A-z]/gi, ''), 10); + } else if (splitString2.length > 1 && theItem.datatype !== 'X') { // Byte/word array + theItem.arrayLength = safeParseInt(splitString2[1].replace(/[A-z]/gi, ''), 1, MAX_BYTE_LENGTH); + if (theItem.arrayLength === undefined) { + outputLog("Error - Invalid array length in address " + addr); + return undefined; + } } else { theItem.arrayLength = 1; } theItem.dbNumber = 0; - theItem.offset = parseInt(splitString2[0].replace(/[A-z]/gi, ''), 10); + theItem.offset = safeParseInt(splitString2[0].replace(/[A-z]/gi, ''), 0, 65535); + if (theItem.offset === undefined) { + outputLog("Error - Invalid offset in address " + addr); + return undefined; + } } if (theItem.datatype === 'DI') { @@ -2724,7 +2969,11 @@ function stringToS7Addr(addr, useraddr, cParam) { theItem.byteLength = theItem.arrayLength * theItem.dtypelen; } - // outputLog(' Arr lenght is ' + theItem.arrayLength + ' and DTL is ' + theItem.dtypelen); + // Validate total byte length does not exceed buffer size + if (theItem.byteLength > MAX_BYTE_LENGTH) { + outputLog("Error - Requested data length (" + theItem.byteLength + " bytes) exceeds maximum (" + MAX_BYTE_LENGTH + ") for address " + addr); + return undefined; + } theItem.byteLengthWithFill = theItem.byteLength; if (theItem.byteLengthWithFill % 2) { theItem.byteLengthWithFill += 1; } // S7 will add a filler byte. Use this expected reply length for PDU calculations. @@ -2885,3 +3134,52 @@ function outputLog(txt, debugLevel, id) { console.log('[' + process.hrtime() + idtext + '] ' + util.format(txt)); } } + +// Promise/async-await API wrappers +NodeS7.prototype.initiateConnectionAsync = function(params) { + var self = this; + return new Promise(function(resolve, reject) { + self.initiateConnection(params, function(err) { + if (err) { reject(err); } else { resolve(); } + }); + }); +}; + +NodeS7.prototype.readAllItemsAsync = function() { + var self = this; + return new Promise(function(resolve, reject) { + self.readAllItems(function(err, values) { + if (err) { reject(new Error('Read quality error')); } else { resolve(values); } + }); + }); +}; + +NodeS7.prototype.writeItemsAsync = function(items, values) { + var self = this; + return new Promise(function(resolve, reject) { + self.writeItems(items, values, function(err) { + if (err) { reject(new Error('Write quality error')); } else { resolve(); } + }); + }); +}; + +NodeS7.prototype.dropConnectionAsync = function() { + var self = this; + return new Promise(function(resolve) { + self.dropConnection(function() { + resolve(); + }); + }); +}; + +// Export internal functions for testing when NODE_ENV=test +if (process.env.NODE_ENV === 'test') { + module.exports._internal = { + stringToS7Addr: stringToS7Addr, + checkRFCData: checkRFCData, + S7AddrToBuffer: S7AddrToBuffer, + processS7Packet: processS7Packet, + safeParseInt: safeParseInt, + S7Error: S7Error + }; +} diff --git a/package.json b/package.json index 464c45a..b3244b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nodes7", - "description": "Routine to communicate with Siemens S7 PLCs", - "version": "0.3.18", + "description": "Communication library for Siemens S7 PLCs via RFC1006/ISO-on-TCP", + "version": "0.4.0", "author": { "name": "Dana Moffit", "email": "nodejsplc@gmail.com" @@ -11,13 +11,33 @@ "Siemens", "PLC", "RFC1006", - "iso-on-tcp" + "iso-on-tcp", + "S7-1200", + "S7-1500", + "S7-300", + "S7-400", + "industrial", + "automation", + "SCADA" ], "repository": { "type": "git", "url": "https://github.com/plcpeople/nodes7" }, "main": "nodeS7.js", - "engine": "node 10.x.x", + "types": "nodeS7.d.ts", + "engines": { + "node": ">=14.0.0" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "files": [ + "nodeS7.js", + "nodeS7.d.ts", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], "license": "MIT" } diff --git a/test/address-parsing.test.js b/test/address-parsing.test.js new file mode 100644 index 0000000..ca44119 --- /dev/null +++ b/test/address-parsing.test.js @@ -0,0 +1,240 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +process.env.NODE_ENV = 'test'; +const NodeS7 = require('../nodeS7'); +const { stringToS7Addr } = NodeS7._internal; + +const defaultCParam = {}; + +describe('stringToS7Addr - Valid DB addresses', function() { + it('should parse DB1,REAL0', function() { + const item = stringToS7Addr('DB1,REAL0', 'DB1,REAL0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'DB'); + assert.strictEqual(item.datatype, 'REAL'); + assert.strictEqual(item.dbNumber, 1); + assert.strictEqual(item.offset, 0); + assert.strictEqual(item.byteLength, 4); + assert.strictEqual(item.arrayLength, 1); + }); + + it('should parse DB10,INT6.2 (array of 2)', function() { + const item = stringToS7Addr('DB10,INT6.2', 'DB10,INT6.2', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'DB'); + assert.strictEqual(item.datatype, 'INT'); + assert.strictEqual(item.dbNumber, 10); + assert.strictEqual(item.offset, 6); + assert.strictEqual(item.arrayLength, 2); + assert.strictEqual(item.byteLength, 4); + }); + + it('should parse DB1,X14.0 (single bit)', function() { + const item = stringToS7Addr('DB1,X14.0', 'DB1,X14.0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'DB'); + assert.strictEqual(item.datatype, 'X'); + assert.strictEqual(item.dbNumber, 1); + assert.strictEqual(item.offset, 14); + assert.strictEqual(item.bitOffset, 0); + assert.strictEqual(item.arrayLength, 1); + }); + + it('should parse DB10,S20.30 (string at offset 20, length 30)', function() { + // DB10,S20.30 = DB10, String type, offset 20, string length 30 + // When parsed: splitString2 = ['S20', '30'], datatype='S' + // For 2-element split with S type: dtypelen = parseInt('30') is NOT correct + // Actually: splitString[1]='S20.30', split('.') = ['S20','30'] + // datatype = 'S20'.replace(/[0-9]/gi,'') = 'S' + // S type + 2 elements: dtypelen = parseInt('20',10) + 2 = 22, arrayLength = NOT set here + // Wait: splitString2.length is 2, so it goes to the (S/STRING && length===2) branch + // dtypelen = parseInt(splitString2[1], 10) + 2 = parseInt('30') + 2 = 32 + const item = stringToS7Addr('DB10,S20.30', 'DB10,S20.30', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'DB'); + assert.ok(item.datatype === 'S' || item.datatype === 'STRING'); + assert.strictEqual(item.dbNumber, 10); + assert.strictEqual(item.offset, 20); + assert.strictEqual(item.dtypelen, 32); // 30 + 2 header + assert.strictEqual(item.arrayLength, 1); + }); + + it('should parse DB1,DWORD0', function() { + const item = stringToS7Addr('DB1,DWORD0', 'DB1,DWORD0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.datatype, 'DWORD'); + assert.strictEqual(item.byteLength, 4); + }); + + it('should parse DB1,LREAL0', function() { + const item = stringToS7Addr('DB1,LREAL0', 'DB1,LREAL0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.datatype, 'LREAL'); + assert.strictEqual(item.byteLength, 8); + }); + + it('should parse DB1,DT0', function() { + const item = stringToS7Addr('DB1,DT0', 'DB1,DT0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.datatype, 'DT'); + assert.strictEqual(item.byteLength, 8); + }); + + it('should parse DB1,DTL0', function() { + const item = stringToS7Addr('DB1,DTL0', 'DB1,DTL0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.datatype, 'DTL'); + assert.strictEqual(item.byteLength, 12); + }); +}); + +describe('stringToS7Addr - Valid non-DB addresses', function() { + it('should parse MR4 (marker real)', function() { + const item = stringToS7Addr('MR4', 'MR4', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'M'); + assert.strictEqual(item.datatype, 'REAL'); + assert.strictEqual(item.offset, 4); + assert.strictEqual(item.dbNumber, 0); + }); + + it('should parse M32.2 (marker bit)', function() { + const item = stringToS7Addr('M32.2', 'M32.2', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'M'); + assert.strictEqual(item.datatype, 'X'); + assert.strictEqual(item.offset, 32); + assert.strictEqual(item.bitOffset, 2); + }); + + it('should parse PIW30 (peripheral input word)', function() { + const item = stringToS7Addr('PIW30', 'PIW30', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'P'); + assert.strictEqual(item.datatype, 'WORD'); + assert.strictEqual(item.offset, 30); + }); + + it('should parse T5 (timer)', function() { + const item = stringToS7Addr('T5', 'T5', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'T'); + assert.strictEqual(item.datatype, 'TIMER'); + assert.strictEqual(item.offset, 5); + }); + + it('should parse C10 (counter)', function() { + const item = stringToS7Addr('C10', 'C10', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'C'); + assert.strictEqual(item.datatype, 'COUNTER'); + assert.strictEqual(item.offset, 10); + }); + + it('should parse I0.0 (input bit)', function() { + const item = stringToS7Addr('I0.0', 'I0.0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'I'); + assert.strictEqual(item.datatype, 'X'); + assert.strictEqual(item.offset, 0); + assert.strictEqual(item.bitOffset, 0); + }); + + it('should parse Q0.0 (output bit)', function() { + const item = stringToS7Addr('Q0.0', 'Q0.0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'Q'); + assert.strictEqual(item.datatype, 'X'); + }); + + it('should parse EB0 (input byte)', function() { + const item = stringToS7Addr('EB0', 'EB0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.addrtype, 'I'); + assert.strictEqual(item.datatype, 'BYTE'); + }); +}); + +describe('stringToS7Addr - Invalid addresses', function() { + it('should reject empty string', function() { + const item = stringToS7Addr('', '', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject _COMMERR', function() { + const item = stringToS7Addr('DB1,REAL0', '_COMMERR', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject DB without number', function() { + const item = stringToS7Addr('DB,REAL0', 'DB,REAL0', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject DB with out-of-range number', function() { + const item = stringToS7Addr('DB99999,REAL0', 'DB99999,REAL0', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject DB0 (DB number must be >= 1)', function() { + const item = stringToS7Addr('DB0,REAL0', 'DB0,REAL0', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject bit offset > 7', function() { + const item = stringToS7Addr('DB1,X14.8', 'DB1,X14.8', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject negative array length', function() { + const item = stringToS7Addr('DB1,INT0.-1', 'DB1,INT0.-1', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject zero array length', function() { + const item = stringToS7Addr('DB1,INT0.0', 'DB1,INT0.0', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject unknown memory area', function() { + const item = stringToS7Addr('Z100', 'Z100', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject non-string input', function() { + const item = stringToS7Addr(123, 'test', defaultCParam); + assert.strictEqual(item, undefined); + }); + + it('should reject too many commas', function() { + const item = stringToS7Addr('DB1,REAL0,extra', 'DB1,REAL0,extra', defaultCParam); + assert.strictEqual(item, undefined); + }); +}); + +describe('stringToS7Addr - Edge cases', function() { + it('should handle max valid DB number (65535)', function() { + const item = stringToS7Addr('DB65535,REAL0', 'DB65535,REAL0', defaultCParam); + assert.ok(item); + assert.strictEqual(item.dbNumber, 65535); + }); + + it('should handle large offset', function() { + const item = stringToS7Addr('DB1,REAL65534', 'DB1,REAL65534', defaultCParam); + assert.ok(item); + assert.strictEqual(item.offset, 65534); + }); + + it('should handle WDT datatype with wdtAsUTC=true', function() { + const item = stringToS7Addr('DB1,WDT0', 'DB1,WDT0', { wdtAsUTC: true }); + assert.ok(item); + assert.strictEqual(item.datatype, 'DTZ'); + }); + + it('should handle WDT datatype with wdtAsUTC=false', function() { + const item = stringToS7Addr('DB1,WDT0', 'DB1,WDT0', { wdtAsUTC: false }); + assert.ok(item); + assert.strictEqual(item.datatype, 'DT'); + }); +}); diff --git a/test/buffer-helpers.test.js b/test/buffer-helpers.test.js new file mode 100644 index 0000000..af086d6 --- /dev/null +++ b/test/buffer-helpers.test.js @@ -0,0 +1,125 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +process.env.NODE_ENV = 'test'; +const NodeS7 = require('../nodeS7'); +const { S7AddrToBuffer, stringToS7Addr, safeParseInt, S7Error } = NodeS7._internal; + +const defaultCParam = {}; + +describe('safeParseInt - Input validation helper', function() { + it('should parse valid integer within range', function() { + assert.strictEqual(safeParseInt('42', 0, 100), 42); + }); + + it('should return undefined for NaN input', function() { + assert.strictEqual(safeParseInt('abc', 0, 100), undefined); + }); + + it('should return undefined for empty string', function() { + assert.strictEqual(safeParseInt('', 0, 100), undefined); + }); + + it('should return undefined for value below min', function() { + assert.strictEqual(safeParseInt('5', 10, 100), undefined); + }); + + it('should return undefined for value above max', function() { + assert.strictEqual(safeParseInt('200', 0, 100), undefined); + }); + + it('should accept boundary values', function() { + assert.strictEqual(safeParseInt('0', 0, 100), 0); + assert.strictEqual(safeParseInt('100', 0, 100), 100); + }); + + it('should handle negative min values', function() { + assert.strictEqual(safeParseInt('-5', -10, 10), -5); + }); +}); + +describe('S7AddrToBuffer - Address to buffer conversion', function() { + it('should create valid buffer for DB REAL address', function() { + const item = stringToS7Addr('DB1,REAL0', 'DB1,REAL0', defaultCParam); + assert.ok(item); + const buf = S7AddrToBuffer(item, false); + assert.ok(Buffer.isBuffer(buf)); + assert.strictEqual(buf.length, 12); + // Check area code (byte 8 high nibble should contain 0x84 for DB) + assert.strictEqual(buf[8] & 0xFF, 0x84); + }); + + it('should create valid buffer for marker address', function() { + const item = stringToS7Addr('MW0', 'MW0', defaultCParam); + assert.ok(item); + const buf = S7AddrToBuffer(item, false); + assert.ok(Buffer.isBuffer(buf)); + assert.strictEqual(buf[8] & 0xFF, 0x83); // Marker area code + }); + + it('should handle bit addressing for single bit write', function() { + const item = stringToS7Addr('DB1,X0.3', 'DB1,X0.3', defaultCParam); + assert.ok(item); + const buf = S7AddrToBuffer(item, true); // isWriting = true + assert.strictEqual(buf[3], 0x01); // BIT length for single bit write + }); + + it('should handle timer area code', function() { + const item = stringToS7Addr('T5', 'T5', defaultCParam); + assert.ok(item); + const buf = S7AddrToBuffer(item, false); + assert.strictEqual(buf[8] & 0xFF, 0x1d); // Timer area code + }); + + it('should handle counter area code', function() { + const item = stringToS7Addr('C10', 'C10', defaultCParam); + assert.ok(item); + const buf = S7AddrToBuffer(item, false); + assert.strictEqual(buf[8] & 0xFF, 0x1c); // Counter area code + }); +}); + +describe('S7Error - Error code constants', function() { + it('should have all expected error codes', function() { + assert.ok(S7Error.TIMEOUT); + assert.ok(S7Error.INVALID_ADDRESS); + assert.ok(S7Error.BUFFER_OVERFLOW); + assert.ok(S7Error.PACKET_MALFORMED); + assert.ok(S7Error.CONNECTION_REFUSED); + assert.ok(S7Error.NOT_CONNECTED); + assert.ok(S7Error.WRITE_IN_PROGRESS); + assert.ok(S7Error.PLC_ERROR); + assert.ok(S7Error.INVALID_ARGUMENT); + }); + + it('should be accessible from NodeS7.errors', function() { + assert.strictEqual(NodeS7.errors, S7Error); + assert.strictEqual(NodeS7.errors.TIMEOUT, 'ERR_S7_TIMEOUT'); + }); +}); + +describe('NodeS7 constructor', function() { + it('should create instance with default options', function() { + const conn = new NodeS7({ silent: true }); + assert.ok(conn); + assert.strictEqual(conn.isoConnectionState, 0); + assert.strictEqual(conn.reconnectCount, 0); + assert.strictEqual(conn.maxReconnectAttempts, Infinity); + assert.strictEqual(conn.baseReconnectDelay, 2000); + }); + + it('should inherit from EventEmitter', function() { + const conn = new NodeS7({ silent: true }); + assert.ok(typeof conn.on === 'function'); + assert.ok(typeof conn.emit === 'function'); + assert.ok(typeof conn.removeListener === 'function'); + }); + + it('should have Promise API methods', function() { + const conn = new NodeS7({ silent: true }); + assert.ok(typeof conn.initiateConnectionAsync === 'function'); + assert.ok(typeof conn.readAllItemsAsync === 'function'); + assert.ok(typeof conn.writeItemsAsync === 'function'); + assert.ok(typeof conn.dropConnectionAsync === 'function'); + }); +}); diff --git a/test/packet-validation.test.js b/test/packet-validation.test.js new file mode 100644 index 0000000..11202f5 --- /dev/null +++ b/test/packet-validation.test.js @@ -0,0 +1,98 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert'); + +process.env.NODE_ENV = 'test'; +const NodeS7 = require('../nodeS7'); +const { checkRFCData } = NodeS7._internal; + +describe('checkRFCData - Packet validation', function() { + it('should reject packets shorter than 7 bytes', function() { + const data = Buffer.from([0x03, 0x00, 0x00, 0x05, 0x02]); + const result = checkRFCData(data); + assert.strictEqual(result, 'error'); + }); + + it('should reject non-buffer input', function() { + const result = checkRFCData('not a buffer'); + assert.strictEqual(result, 'error'); + }); + + it('should reject null input', function() { + const result = checkRFCData(null); + assert.strictEqual(result, 'error'); + }); + + it('should reject wrong RFC version', function() { + // RFC version 0x04 instead of 0x03 + const data = Buffer.from([0x04, 0x00, 0x00, 0x07, 0x02, 0xf0, 0x00]); + const result = checkRFCData(data); + assert.strictEqual(result, 'error'); + }); + + it('should reject wrong TPDU code', function() { + // TPDU code 0xe0 instead of 0xf0 + const data = Buffer.from([0x03, 0x00, 0x00, 0x07, 0x02, 0xe0, 0x00]); + const result = checkRFCData(data); + assert.strictEqual(result, 'error'); + }); + + it('should detect fast acknowledge packet', function() { + // Valid fast ACK: RFC=0x03, length=7, TPDU=0xf0, LastDataUnit bit 7 = 0 + const data = Buffer.from([0x03, 0x00, 0x00, 0x07, 0x02, 0xf0, 0x00]); + const result = checkRFCData(data); + assert.strictEqual(result, 'fastACK'); + }); + + it('should accept valid S7 data packet', function() { + // Valid S7 data: RFC=0x03, TPDU=0xf0, LastDataUnit bit 7 = 1 (0x80) + const data = Buffer.from([ + 0x03, 0x00, 0x00, 0x1b, 0x02, 0xf0, 0x80, + 0x32, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0xf0 + ]); + const result = checkRFCData(data); + assert.ok(Buffer.isBuffer(result)); + assert.strictEqual(result.length, data.length); + }); + + it('should handle double fast ACK + S7 data packet', function() { + // Fast ACK (7 bytes) + S7 data packet + const fastAck = Buffer.from([0x03, 0x00, 0x00, 0x07, 0x02, 0xf0, 0x00]); + const s7Data = Buffer.from([ + 0x03, 0x00, 0x00, 0x1b, 0x02, 0xf0, 0x80, + 0x32, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0xf0 + ]); + const combined = Buffer.concat([fastAck, s7Data]); + const result = checkRFCData(combined); + // Should slice off the fast ACK and return the S7 data + assert.ok(Buffer.isBuffer(result)); + assert.strictEqual(result.length, s7Data.length); + }); + + it('should reject packet with invalid TPKT length (too small)', function() { + // TPKT length = 3 (less than minimum 7) + const data = Buffer.from([0x03, 0x00, 0x00, 0x03, 0x02, 0xf0, 0x00]); + const result = checkRFCData(data); + assert.strictEqual(result, 'error'); + }); +}); + +describe('checkRFCData - Security edge cases', function() { + it('should handle TPKT length mismatch (declared > actual)', function() { + // TPKT says 100 bytes but buffer is only 7 + const data = Buffer.from([0x03, 0x00, 0x00, 0x64, 0x02, 0xf0, 0x80]); + const result = checkRFCData(data); + // Should still process it since LastDataUnit=1 and TPKT_Length > data.length + // The existing logic returns 'error' for this case + assert.strictEqual(result, 'error'); + }); + + it('should handle empty buffer', function() { + const data = Buffer.alloc(0); + const result = checkRFCData(data); + assert.strictEqual(result, 'error'); + }); +});