WebAssembly has a stack that lives separately from the linear memory, but C++ compiled to WebAssembly generally manages its own parallel "shadow stack" in linear memory that it keeps some of its stack variables inside. (I believe you can't have pointers into the WebAssembly stack, so anything that might need to be pointed to can't live in it.)
Because the shadow stack is created and managed by the WebAssembly binary itself, it would be its responsibility to add protections like stack protectors or ASLR on it within the linear memory if it wants them. A WebAssembly JIT isn't ever going to touch the linear memory in a way the binary doesn't specify.
>But function addresses can still be randomly offset, no?
No, functions have fixed indexes in a WebAssembly binary. You can't dynamically reassign the indexes at runtime.
You do have the benefit that if your program tries to jump to a function index chosen by an attacker, the attacker can only jump to a function with a compatible type signature. The attacker can't do anything too clever like jumping partway into an arbitrary function, jumping into attacker-written code in the mutable linear memory, or queuing up a series of return addresses to pull off return-oriented-programming.