Skip to content

PUT with unknown critical option #99 updates /store instead of returning 4.02 Bad Option #419

Description

@gahssi

Summary

I found that aiocoap accepts a Confirmable PUT carrying unknown critical option number 99 and applies the PUT payload to /store, while the comparison implementations reject the same request with 4.02 Bad Option and keep the previous resource value.

This issue is based on a three-packet raw CoAP sequence against a simple /store resource:

  1. write marker value A to /store;
  2. send the edge-case PUT carrying marker value B;
  3. read /store back with a fresh GET.

On aiocoap, the probe returned 2.04 Changed. A subsequent GET returned marker value B, showing that the PUT carrying the unknown critical option changed /store.

Relevant RFC text

RFC 7252, Section 5.4.1, says:

Unrecognized options of class "critical" that occur in a Confirmable request MUST cause the return of a 4.02 (Bad Option) response.

The same section says the response should include a diagnostic payload describing the unrecognized option(s). RFC 7252, Section 5.9.2.3, defines 4.02 Bad Option as the response code for a request that could not be understood because of unrecognized or malformed options.

Tested version

  • Version/provenance observed locally: 0.4.17
  • Transport: CoAP over UDP, local test server
  • Resource used by the test: /store

Expected behavior

Because the probe is a Confirmable PUT containing an unrecognized critical option (option number 99), I expected the server to return 4.02 Bad Option and leave /store at marker value A.

Observed behavior

Step Response observed from this target Response payload
setup-put-store-a ACK 2.04 empty
unknown-critical-state-effect-probe ACK 2.04 empty
fresh-get-store-readout ACK 2.05 marker value B

Request packet sequence

Step Purpose CoAP fields
setup-put-store-a Create/update /store with marker value A. PUT; MID 28432; Token 66f301; path /store; payload marker value A
unknown-critical-state-effect-probe Attempt to update /store with marker value B while carrying the unknown critical option. PUT; MID 28433; Token 66f302; path /store; payload marker value B; unknown critical option number 99 with value 01
fresh-get-store-readout Read /store using a fresh GET request. GET; MID 28434; Token 66f3ff; path /store

The issue folder also includes each request as .hex and .bin under raw_datagrams/.

Cross-implementation comparison

Implementation Version Probe response Final /store readout Result
libcoap 4.3.5b 4.02 marker value A Rejected unknown critical option; state preserved
Californium 3.14.0 4.02 marker value A Rejected unknown critical option; state preserved
aiocoap 0.4.17 2.04 marker value B Accepted request; state changed
go-coap 3.5.1 2.04 marker value B Accepted request; state changed

Minimal reproduction

I included a small raw UDP reproducer:

python3 reproduce_raw_coap_sequence.py --host 127.0.0.1 --port 56833
**reproduce_raw_coap_sequence.py**
from __future__ import annotations

import argparse
import socket
import time

PACKETS = [('setup-put-store-a', '43036f1066f301b573746f726510ff61746c61732d637269746963616c2d41'),
    ('unknown-critical-state-effect-probe', '43036f1166f302b573746f726510d14a01ff61746c61732d637269746963616c2d42'),
    ('fresh-get-store-readout', '43016f1266f3ffb573746f7265')]


def coap_code_name(code_byte: int) -> str:
    klass = code_byte >> 5
    detail = code_byte & 0x1F
    return f"{klass}.{detail:02d}"


def payload_from_response(raw: bytes) -> str:
    try:
        marker = raw.index(b"\xff")
    except ValueError:
        return ""
    payload = raw[marker + 1:]
    try:
        return payload.decode("utf-8")
    except UnicodeDecodeError:
        return payload.hex()


def main() -> int:
    parser = argparse.ArgumentParser(description="Replay this issue's raw CoAP datagrams")
    parser.add_argument("--host", default='127.0.0.1', help="target host/IP")
    parser.add_argument("--port", type=int, default=56833, help="target UDP port")
    parser.add_argument("--timeout", type=float, default=2.0, help="receive timeout per packet")
    parser.add_argument("--delay", type=float, default=0.15, help="delay between packets")
    args = parser.parse_args()

    addr = (args.host, args.port)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.settimeout(args.timeout)
        print(f"target={args.host}:{args.port}")
        for step, hex_bytes in PACKETS:
            raw = bytes.fromhex(hex_bytes)
            print(f"\n>>> {step}")
            print(f"sent_hex={hex_bytes}")
            sock.sendto(raw, addr)
            try:
                response, peer = sock.recvfrom(4096)
            except socket.timeout:
                print("response=timeout")
            else:
                code = coap_code_name(response[1]) if len(response) >= 2 else "parse-error"
                payload = payload_from_response(response)
                print(f"from={peer[0]}:{peer[1]}")
                print(f"response_code={code}")
                print(f"response_payload={payload!r}")
                print(f"response_hex={response.hex()}")
            time.sleep(args.delay)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The script sends the exact datagrams listed above from a single UDP socket and prints each raw response as hex, the CoAP response code, and any decoded response payload.

target=127.0.0.1:56833

>>> setup-put-store-a
sent_hex=43036f1066f301b573746f726510ff61746c61732d637269746963616c2d41
from=127.0.0.1:56833
response_code=2.04
response_payload=''
response_hex=63446f1066f301

>>> unknown-critical-state-effect-probe
sent_hex=43036f1166f302b573746f726510d14a01ff61746c61732d637269746963616c2d42
from=127.0.0.1:56833
response_code=2.04
response_payload=''
response_hex=63446f1166f302

>>> fresh-get-store-readout
sent_hex=43016f1266f3ffb573746f7265
from=127.0.0.1:56833
response_code=2.05
response_payload='c0ff61746c61732d637269746963616c2d42'
response_hex=63456f1266f3ffc0ff61746c61732d637269746963616c2d42

Notes

I have not inferred a root cause from this test. I am sharing the raw packet sequence and cross-implementation comparison so maintainers can confirm whether the behavior is intended for this implementation.

My aiocoap.cli.defaults

Python version: 3.10.12 (main, Jan 26 2026, 14:55:28) [GCC 11.4.0]
aiocoap version: 0.4.17
Modules missing for subsystems:
    dtls: missing DTLSSocket
    oscore: missing cbor2, cryptography, filelock, ge25519, lakers-python
    linkheader: everything there
    prettyprint: missing cbor2, cbor-diag
    ws: missing websockets
Python platform: linux
Default server transports:  tcpserver:tcpclient:tlsserver:tlsclient:udp6
Selected server transports: tcpserver:tcpclient:tlsserver:tlsclient:udp6
Default client transports:  tcpclient:tlsclient:udp6
Selected client transports: tcpclient:tlsclient:udp6
SO_REUSEPORT available (default, selected): True, True

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions