How to Fix: `fd_fdstat_set_rights` does not error when attempting to add rights to stdin/out/err
fd_fdstat_set_rights should reject rights escalation on stdin, stdout, and stderr—but in this bug, it silently accepts it. That breaks expected WASI capability semantics and allows a file descriptor rights update that should never succeed for the preopened standard streams.
Table of Contents
Issue Overview
The GitHub issue titled fd_fdstat_set_rights does not error when attempting to add rights to stdin/out/err points to a capability enforcement bug in a WASI implementation. Under WASI, file descriptors carry a set of allowed operations, represented as base rights and inheriting rights. The contract of fd_fdstat_set_rights is restrictive: it may only reduce rights, never add rights that were not already granted.
That rule matters especially for file descriptors 0, 1, and 2, which map to stdin, stdout, and stderr. These descriptors are created by the runtime, and their rights should be bounded by the host configuration. If a guest module can call fd_fdstat_set_rights and gain new rights, the runtime is violating WASI’s capability model.
In practice, the failing behavior looks like this:
- A module calls
fd_fdstat_set_rightsonstdin,stdout, orstderr. - The requested rights include bits not currently present.
- Instead of returning an error such as
ERRNO_NOTCAPABLEor the implementation-specific equivalent, the call succeeds.
This tutorial explains why that happens, how to fix it correctly in the runtime, and how to validate the patch with focused tests.
Understanding the Root Cause
The root cause is almost always a missing or incomplete rights subset check.
The WASI API expects fd_fdstat_set_rights(fd, fs_rights_base, fs_rights_inheriting) to behave like a capability narrowing operation. In other words, the new rights must be a subset of the existing rights already attached to that file descriptor.
Technically, the implementation should enforce logic equivalent to:
if ((new_base & ~current_base) != 0) return ERRNO_NOTCAPABLE;
if ((new_inheriting & ~current_inheriting) != 0) return ERRNO_NOTCAPABLE;
If that validation is missing, reversed, or applied after rights are already overwritten, the runtime may accidentally permit rights escalation.
Why does this show up clearly on stdin/stdout/stderr?
- These descriptors are always present and easy to test.
- They often have minimal rights configured by default.
- Any successful attempt to add extra rights is immediately visible as a capability violation.
There are a few common implementation mistakes behind this bug:
- No subset validation: the code writes the new rights directly into the descriptor metadata.
- Validation against descriptor type, not current descriptor rights: for example, allowing all rights valid for a stream instead of only the rights currently granted.
- Special-casing standard FDs incorrectly: treating
0,1, and2as always mutable without capability checks. - Bitmask logic bug: using
(current & ~new)instead of(new & ~current), which checks reduction rather than escalation.
In short, the bug happens because the runtime treats fd_fdstat_set_rights as a generic setter instead of a monotonic capability reducer.
Step-by-Step Solution
The fix is to reject any request that tries to add rights not already present on the target file descriptor.
1. Locate the fd_fdstat_set_rights implementation
Find the handler in your WASI runtime, host shim, or syscall layer. It often lives in code responsible for translating WASI syscalls into host operations.
// Pseudocode structure
Errno fd_fdstat_set_rights(Fd fd, Rights new_base, Rights new_inheriting) {
Descriptor* desc = lookup_fd(fd);
if (desc == NULL) return ERRNO_BADF;
// validate requested rights here
...
desc->rights_base = new_base;
desc->rights_inheriting = new_inheriting;
return ERRNO_SUCCESS;
}
2. Read the current rights before mutating anything
Make sure the existing rights are loaded from the descriptor state first. Do not compare against descriptor defaults or against what the descriptor type could theoretically support.
Rights current_base = desc->rights_base;
Rights current_inheriting = desc->rights_inheriting;
3. Enforce subset semantics
This is the core fix. The requested rights must not contain any bit absent from the current rights.
if ((new_base & ~current_base) != 0) {
return ERRNO_NOTCAPABLE;
}
if ((new_inheriting & ~current_inheriting) != 0) {
return ERRNO_NOTCAPABLE;
}
This bitmask pattern works because ~current_base marks every right the descriptor does not have. If any of those forbidden bits appear in new_base, the request is an escalation and must fail.
4. Only update rights after validation passes
Do not partially write one field and then fail the other. Treat the operation as atomic from the caller’s perspective.
desc->rights_base = new_base;
desc->rights_inheriting = new_inheriting;
return ERRNO_SUCCESS;
5. Do not exempt stdin, stdout, or stderr
Standard streams should follow the same capability rules as any other file descriptor. They may be preinstalled by the runtime, but they are not exempt from rights monotonicity.
// Avoid logic like this
if (fd == 0 || fd == 1 || fd == 2) {
desc->rights_base = new_base;
desc->rights_inheriting = new_inheriting;
return ERRNO_SUCCESS;
}
If such a branch exists, remove it or route it through the same subset validation.
6. Add a regression test using the reported pattern
The issue description includes a WAT test case. Build a regression test that attempts to add rights to the standard descriptors and asserts that the syscall returns an error.
(module
(type (func (param i32 i64 i64) (result i32)))
(import "wasi_snapshot_preview1" "fd_fdstat_set_rights"
(func $fd_fdstat_set_rights (type 0)))
;; Example: try to add rights on stdout (fd 1)
;; The exact constants depend on your test harness and rights layout.
;; The expected result is a non-zero errno.
)
If your runtime uses a higher-level test harness, express the same scenario in unit or integration form:
#[test]
fn cannot_add_rights_to_stdout() {
let fd = 1; // stdout
let current = get_fd_rights(fd);
let extra = RIGHT_FD_SEEK; // choose a bit not currently granted
let result = fd_fdstat_set_rights(fd, current.base | extra, current.inheriting);
assert_eq!(result, ERRNO_NOTCAPABLE);
}
7. Test all three standard descriptors
Do not stop at one stream. The regression suite should cover:
stdinrights escalation attemptstdoutrights escalation attemptstderrrights escalation attempt- A valid rights reduction case that should still succeed
#[test]
fn reducing_rights_is_allowed() {
let fd = 1;
let current = get_fd_rights(fd);
let reduced = current.base & !RIGHT_FD_WRITE; // example reduction
let result = fd_fdstat_set_rights(fd, reduced, current.inheriting);
assert_eq!(result, ERRNO_SUCCESS);
}
8. Verify behavior against the WASI contract
After the patch:
- Adding any new right should fail.
- Keeping the exact same rights should succeed.
- Reducing rights should succeed.
- The rule should be identical for normal files, pipes, sockets if supported, and standard descriptors.
Common Edge Cases
Even after adding the subset check, a few edge cases can still produce incorrect behavior.
1. Base rights and inheriting rights validated inconsistently
Some implementations validate base rights correctly but forget to validate inheriting rights. That still permits a form of capability escalation.
if ((new_inheriting & ~current_inheriting) != 0) {
return ERRNO_NOTCAPABLE;
}
2. Rights are recomputed from descriptor kind
If your runtime derives rights from a file type every time instead of storing the actual current rights, a caller may appear to gain rights that were previously dropped. Once rights are reduced, the reduced set must remain the source of truth.
3. 64-bit bitmask truncation
WASI rights are typically represented as 64-bit masks. If the implementation stores them in a narrower type or uses unsafe casting, high-order bits may be lost, causing false positives or false negatives in the capability check.
using Rights = uint64_t; // not uint32_t
4. Partial mutation before failure
If one rights field is written before validating the other, a failed call can still mutate descriptor state. Ensure both checks happen before any update.
5. Error code mismatch
Different runtimes sometimes return the wrong errno, such as ERRNO_INVAL instead of ERRNO_NOTCAPABLE. Even if the call fails, the wrong error code can break spec-conformance tests.
6. Shared descriptor tables or aliasing bugs
If multiple entries reference the same underlying descriptor object, changing rights for one alias may affect another. That can make standard stream tests behave unpredictably in multi-handle implementations.
FAQ
Should fd_fdstat_set_rights ever allow adding rights?
No. Under the WASI capability model, fd_fdstat_set_rights is intended to reduce rights, not expand them. Any attempt to add rights that were not already granted should fail.
Why are stdin, stdout, and stderr important in this bug?
They are the most visible preopened descriptors and are easy to exercise in a regression test. If rights escalation is possible there, it is a strong sign the implementation is violating capability rules more broadly.
What error should the runtime return when new rights are requested?
The correct result is typically ERRNO_NOTCAPABLE, because the caller is attempting an operation outside the descriptor’s granted capabilities. Check your runtime’s WASI version and conformance expectations, but do not silently succeed.
Final Verification Checklist
- Validate new base rights as a subset of current base rights.
- Validate new inheriting rights as a subset of current inheriting rights.
- Apply checks to stdin, stdout, and stderr with no exemptions.
- Use 64-bit rights masks consistently.
- Update descriptor state only after both checks pass.
- Add regression tests for all three standard descriptors and one successful reduction case.
Once these checks are in place, the bug is resolved at the correct abstraction layer: the runtime once again enforces WASI rights monotonicity, and fd_fdstat_set_rights behaves as a safe capability-reduction syscall instead of an unintended rights escalation path.