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:
- write marker value A to
/store;
- send the edge-case PUT carrying marker value B;
- 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
Summary
I found that
aiocoapaccepts a Confirmable PUT carrying unknown critical option number 99 and applies the PUT payload to/store, while the comparison implementations reject the same request with4.02 Bad Optionand keep the previous resource value.This issue is based on a three-packet raw CoAP sequence against a simple
/storeresource:/store;/storeback with a fresh GET.On
aiocoap, the probe returned2.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:
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
0.4.17/storeExpected 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 Optionand leave/storeat marker value A.Observed behavior
setup-put-store-aACK 2.04unknown-critical-state-effect-probeACK 2.04fresh-get-store-readoutACK 2.05Request packet sequence
setup-put-store-a/storewith marker value A.28432; Token66f301; path/store; payloadmarker value Aunknown-critical-state-effect-probe/storewith marker value B while carrying the unknown critical option.28433; Token66f302; path/store; payloadmarker value B; unknown critical option number 99 with value01fresh-get-store-readout/storeusing a fresh GET request.28434; Token66f3ff; path/storeThe issue folder also includes each request as
.hexand.binunderraw_datagrams/.Cross-implementation comparison
/storereadoutMinimal reproduction
I included a small raw UDP reproducer:
**reproduce_raw_coap_sequence.py**
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.
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