diff --git a/docs/setup.md b/docs/setup.md index 83712b8..81dc45e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -752,7 +752,7 @@ asymmetric operations. The initial allowlist supports `operation=key_generate` and `operation=jwt_sign` with `algorithm=ES256`. Key generation returns P-256 public/private JWKs plus the RFC 7638 thumbprint (`kid`). JWT signing accepts only a consistent P-256 private JWK, forces `alg=ES256`, and emits a compact JWT with -a 64-byte JOSE signature. Unknown operations and algorithms fail closed. +a 64-byte JOSE signature. It also supports `cbor_decode`, `cose_es256_parse`, and `es256_verify` with `algorithm=ES256`: inputs are canonical unpadded base64url fields; requests are capped at 32 KiB, while CBOR input is definite-length only and capped at 16 KiB decoded (21,846 base64url characters), 256 nodes, and depth 16; it rejects duplicate keys and trailing bytes, and returns an explicit typed tree (`unsigned`, `negative`, `bytes`, `text`, `array`, `map`) rather than DValue-coerced map keys. COSE accepts only EC2/-7/P-256 with exact 32-byte coordinates and an OpenSSL-valid point. Verification accepts only complete canonical DER ECDSA over `message_base64url` and returns `valid`; malformed material fails closed. V1 has no attestation, Ed25519, or RS256. Unknown operations and algorithms fail closed. Existing typed digest, HMAC, password, randomness, and constant-time comparison functions remain separate. `crypto_operation()` exposes no raw signing, arbitrary diff --git a/scripts/run_cli_tests.sh b/scripts/run_cli_tests.sh index f2f142e..6d13a27 100755 --- a/scripts/run_cli_tests.sh +++ b/scripts/run_cli_tests.sh @@ -67,6 +67,7 @@ if [[ "$action" == "run" ]]; then curl -sS --max-time "$curl_timeout" --fail-with-body --unix-socket "$socket_path" "${base_url}&group=${group}" done scripts/test_dependency_invalidation.sh + scripts/test_compiler_lock_directory.sh scripts/test_abi_generation_rollout.sh scripts/test_parallel_precompile.sh timeout --signal=TERM --kill-after=5s 175s scripts/test_parallel_proactive_compile.sh @@ -78,6 +79,7 @@ if [[ "$action" == "run" ]]; then scripts/test_relative_component_cache.sh scripts/test_password_hashing.sh scripts/test_crypto_operation_native.sh + scripts/test_hardened_http_native.sh scripts/test_mysql_epoch_refresh.sh scripts/test_mysql_persistent_pool.sh scripts/test_mysql_persistent_pool_idle.sh diff --git a/scripts/test_compiler_lock_directory.sh b/scripts/test_compiler_lock_directory.sh new file mode 100755 index 0000000..5302eb6 --- /dev/null +++ b/scripts/test_compiler_lock_directory.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +if [[ "${1:-}" != "--inside" ]]; then + exec timeout --signal=TERM --kill-after=5s 90s unshare --mount --fork --kill-child=TERM "$0" --inside +fi + +root="/tmp/uce-compiler-lock-directory-$$" +site="$root/site" +work="$root/missing/parents/work" +settings="$root/settings.cfg" +log="$root/service.log" +server_pid="" + +cleanup() { + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill -TERM "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + rm -rf "$root" +} +trap cleanup EXIT +mkdir -p "$site" "$root/run" "$root/session" "$root/upload" +printf '%s\n' 'CLI(Request& context) { print("recursive-lock-directory-ok"); }' >"$site/test.uce" +cp /etc/uce/settings.cfg "$settings" +cat >>"$settings" <"$log" 2>&1 & +server_pid=$! +deadline=$((SECONDS + 20)) +while [[ ! -S "$root/run/cli.sock" ]] && (( SECONDS < deadline )); do sleep 0.05; done +[[ -S "$root/run/cli.sock" ]] || { cat "$log" >&2; exit 1; } + +rm -rf "$work" +response=$(curl -sS --max-time 45 --fail-with-body --unix-socket "$root/run/cli.sock" http://localhost/test.uce) || { cat "$log" >&2; exit 1; } +[[ "$response" == *"recursive-lock-directory-ok"* ]] || { printf '%s\n' "$response" >&2; cat "$log" >&2; exit 1; } +find "$work" -type f -name '*.lock' -print -quit | grep -q . || { echo "compiler did not recreate a nested lock path" >&2; cat "$log" >&2; exit 1; } +echo "compiler recursive lock directory passed" diff --git a/scripts/test_crypto_operation_native.sh b/scripts/test_crypto_operation_native.sh index f326cb2..40e5a65 100755 --- a/scripts/test_crypto_operation_native.sh +++ b/scripts/test_crypto_operation_native.sh @@ -40,6 +40,11 @@ String base64_decode(String raw, bool& ok) #include "src/lib/hash.cpp" +static String b64url_encode(String text) +{ + String out=replace(replace(base64_encode(text),"+","-"),"/","_"); while(!out.empty()&&out.back()=='=') out.pop_back(); return out; +} + static String b64url_decode(String text) { text = replace(replace(text, "-", "+"), "_", "/"); @@ -82,22 +87,90 @@ int main() DValue wrong_curve = key["private_jwk"]; wrong_curve["crv"] = "P-384"; DValue malformed = key["private_jwk"]; malformed["x"] = "bad="; DValue mismatch = key["private_jwk"]; String d = mismatch["d"].to_string(); d[0] = d[0] == 'A' ? 'B' : 'A'; mismatch["d"] = d; + const String cose="pQECAyYgASFYIGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWIlggT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", message="d2ViYXV0aG4gZml4ZWQgbWVzc2FnZQ", signature="MEUCIQCkatZK1VVsjk17uvyzyhjdAkMNWXPjxSOMqWcjmM_8XAIgDaSk3Qufyd0_6r9Dm9A8RQbFco-FdTBulq7bvRGoBC4"; + auto op = [&](String operation) { DValue r; r["operation"]=operation; r["algorithm"]="ES256"; return r; }; + auto parse = [&](String key) { DValue r=op("cose_es256_parse"); r["cose_key_base64url"]=key; return crypto_operation_native(r); }; + auto verify_cose = [&](String key,String msg,String sig) { DValue r=op("es256_verify"); r["cose_key_base64url"]=key; r["message_base64url"]=msg; r["signature_der_base64url"]=sig; return crypto_operation_native(r); }; + auto decode_cbor = [&](String bytes) { DValue r=op("cbor_decode"); r["cbor_base64url"]=b64url_encode(bytes); return crypto_operation_native(r); }; DValue unsupported; unsupported["operation"] = "encrypt"; unsupported["algorithm"] = "ES256"; DValue unknown_algorithm; unknown_algorithm["operation"] = "key_generate"; unknown_algorithm["algorithm"] = "none"; DValue untyped_algorithm = key_request; untyped_algorithm["algorithm"] = (f64)256; DValue list_header; list_header.set_array(); DValue list_item; list_item = "not-an-object"; list_header.push(list_item); DValue list_request; list_request["operation"] = "jwt_sign"; list_request["algorithm"] = "ES256"; list_request["private_jwk"] = key["private_jwk"]; list_request["protected_header"] = list_header; list_request["claims"] = claims; DValue control_request = list_request; control_request["protected_header"] = header; control_request["claims"] = claims; control_request["claims"]["bad"] = String("control\nbyte"); - DValue oversized = key_request; oversized["ignored"] = String(17000, 'x'); + DValue oversized = key_request; oversized["ignored"] = String(33000, 'x'); DValue nonfinite = key_request; nonfinite["ignored"] = std::numeric_limits::quiet_NaN(); - String tampered = jwt; if(!tampered.empty()) tampered[tampered.size() - 1] = tampered.back() == 'A' ? 'B' : 'A'; + String tampered = jwt; if(tampered.size() > 2) tampered[tampered.size() - 2] = tampered[tampered.size() - 2] == 'A' ? 'B' : 'A'; bool kid_ok = key["ok"].to_bool() && key["kid"].to_string() == key["thumbprint"].to_string(); bool signed_ok = signed_result["ok"].to_bool() && jwt != "" && verify(key["public_jwk"], jwt); bool tamper_ok = !verify(key["public_jwk"], tampered); + auto cbor_error = [&](String bytes) { return(decode_cbor(bytes)["error"].to_string() == "invalid_cbor"); }; + auto cose_error = [&](String encoded) { return(parse(encoded)["error"].to_string() == "invalid_cose_key"); }; + String cose_raw = b64url_decode(cose); + String wrong_kty = cose_raw; wrong_kty[2] = 1; + String wrong_alg = cose_raw; wrong_alg[4] = 0x27; + String wrong_cose_curve = cose_raw; wrong_cose_curve[6] = 2; + String missing_label = cose_raw; missing_label[0] = 0xa4; missing_label.resize(missing_label.size() - 35); + String short_x = String("\xa5\x01\x02\x03\x26\x20\x01\x21\x58\x1f",10) + String(31, 'x') + String("\x22\x58\x20", 3) + String(32, 'y'); + String duplicate_label = cose_raw; duplicate_label[0] = 0xa6; duplicate_label += String("\x01\x02", 2); + String invalid_point = String("\xa5\x01\x02\x03\x26\x20\x01\x21\x58\x20", 10) + String(32, 'x') + String("\x22\x58\x20", 3) + String(32, 'y'); + String der = b64url_decode(signature); + String tampered_der = der; tampered_der[10] ^= 1; + String trailing_der = der + String("\x00", 1); + String noncanonical_der = der; noncanonical_der[1]++; noncanonical_der[3]++; noncanonical_der.insert(4, 1, '\0'); + DValue valid_verify = verify_cose(cose, message, signature); + DValue tampered_signature = verify_cose(cose, message, b64url_encode(tampered_der)); + DValue tampered_message = verify_cose(cose, b64url_encode("tampered"), signature); + DValue malformed_der = verify_cose(cose, message, b64url_encode(String("\x30\x00", 2))); + DValue noncanonical_der_result = verify_cose(cose, message, b64url_encode(noncanonical_der)); + DValue trailing_der_result = verify_cose(cose, message, b64url_encode(trailing_der)); + DValue invalid_point_result = verify_cose(b64url_encode(invalid_point), message, signature); + String large_cbor = String("\x58\x81", 2) + String(129, 'x'); + String oversized_cbor = String("\x5a\x00\x00\x40\x00", 5) + String(16384, 'x'); + String duplicate_compound = String("\xa2\x82\x41[\x41]\x01\x82\x41[\x41]\x02", 13); + String distinct_compound = String("\xa2\x82\x41[\x41]\x01\x82\x41[\x41[\x02", 13); + String node_overflow = String("\x99\x01\x00", 3) + String(256, 0); + String decoded; + bool cbor_valid = decode_cbor(String("\x82\x01\x62ok", 5))["ok"].to_bool(); + bool cbor_large = decode_cbor(large_cbor)["ok"].to_bool(); + bool cbor_control_text = decode_cbor(String("\x61\x01", 2))["ok"].to_bool(); + bool cbor_duplicate = cbor_error(String("\xa2\x01\x02\x01\x03", 5)); + bool cbor_compound_duplicate = cbor_error(duplicate_compound); + bool cbor_compound_distinct = decode_cbor(distinct_compound)["ok"].to_bool(); + bool cbor_invalid_utf8 = cbor_error(String("\x61\x80", 2)); + bool cbor_truncated = cbor_error(String("\xa1", 1)); + bool cbor_trailing = cbor_error(String("\x01\x02", 2)); + bool cbor_depth = cbor_error(String(17, '\x81') + "\x00"); + bool cbor_nodes = cbor_error(node_overflow); + bool cbor_size = cbor_error(oversized_cbor); + bool cbor_indefinite = cbor_error(String("\x9f\x01\xff", 3)); + bool cbor_integer_overflow = cbor_error(String("\x5b\xff\xff\xff\xff\xff\xff\xff\xff", 9)); + bool cbor_nonminimal = cbor_error(String("\x18\x17", 2)); + bool b64_padding = !uce_base64url_decode(cose + "=", decoded, UCE_CBOR_MAX_BASE64URL); + bool b64_truncated = !uce_base64url_decode("A", decoded, UCE_CBOR_MAX_BASE64URL); + bool b64_trailing_bits = !uce_base64url_decode("AB", decoded, UCE_CBOR_MAX_BASE64URL); + bool cose_valid = parse(cose)["ok"].to_bool(); + bool cose_wrong_kty = cose_error(b64url_encode(wrong_kty)); + bool cose_wrong_alg = cose_error(b64url_encode(wrong_alg)); + bool cose_wrong_curve = cose_error(b64url_encode(wrong_cose_curve)); + bool cose_short_coordinate = cose_error(b64url_encode(short_x)); + bool cose_missing_label = cose_error(b64url_encode(missing_label)); + bool cose_duplicate_label = cose_error(b64url_encode(duplicate_label)); + bool cose_invalid_point = invalid_point_result["error"].to_string() == "invalid_key_or_payload"; + bool verify_valid = valid_verify["ok"].to_bool() && valid_verify["valid"].to_bool(); + bool verify_tampered_signature = tampered_signature["ok"].to_bool() && !tampered_signature["valid"].to_bool(); + bool verify_tampered_message = tampered_message["ok"].to_bool() && !tampered_message["valid"].to_bool(); + bool verify_malformed_der = malformed_der["error"].to_string() == "invalid_signature"; + bool verify_noncanonical_der = noncanonical_der_result["error"].to_string() == "invalid_signature"; + bool verify_trailing_der = trailing_der_result["error"].to_string() == "invalid_signature"; + bool cbor_ok = cbor_valid && cbor_large && cbor_control_text && cbor_duplicate && cbor_compound_duplicate && cbor_compound_distinct && cbor_invalid_utf8 && cbor_truncated && cbor_trailing && cbor_depth && cbor_nodes && cbor_size && cbor_indefinite && cbor_integer_overflow && cbor_nonminimal; + bool cose_ok = cose_valid && cose_wrong_kty && cose_wrong_alg && cose_wrong_curve && cose_short_coordinate && cose_missing_label && cose_duplicate_label && cose_invalid_point; + bool cose_negative = verify_valid && verify_tampered_signature && verify_tampered_message && verify_malformed_der && verify_noncanonical_der && verify_trailing_der; + bool b64_ok = b64_padding && b64_truncated && b64_trailing_bits; bool negatives_ok = !sign(wrong_curve)["ok"].to_bool() && !sign(malformed)["ok"].to_bool() && !sign(mismatch)["ok"].to_bool() && crypto_operation_native(unsupported)["error"].to_string() == "unsupported_operation" && crypto_operation_native(unknown_algorithm)["error"].to_string() == "unsupported_algorithm" && crypto_operation_native(untyped_algorithm)["error"].to_string() == "invalid_request" && crypto_operation_native(list_request)["error"].to_string() == "invalid_key_or_payload" && crypto_operation_native(control_request)["error"].to_string() == "invalid_request" && crypto_operation_native(oversized)["error"].to_string() == "invalid_request" && crypto_operation_native(nonfinite)["error"].to_string() == "invalid_request"; - if(!(kid_ok && signed_ok && tamper_ok && negatives_ok)) std::cerr << "kid=" << kid_ok << " signed=" << signed_ok << " tamper=" << tamper_ok << " negatives=" << negatives_ok << " jwt_size=" << jwt.size() << "\\n"; - return(kid_ok && signed_ok && tamper_ok && negatives_ok ? 0 : 1); + if(!(kid_ok && signed_ok && tamper_ok && negatives_ok && cose_ok && cbor_ok && cose_negative && b64_ok)) std::cerr << "kid=" << kid_ok << " signed=" << signed_ok << " tamper=" << tamper_ok << " negatives=" << negatives_ok << " cbor=" << cbor_ok << " cose=" << cose_ok << " verify=" << cose_negative << " b64=" << b64_ok << " large=" << cbor_large << " compound=" << cbor_compound_duplicate << "/" << cbor_compound_distinct << " der=" << verify_malformed_der << "/" << verify_noncanonical_der << "/" << verify_trailing_der << " cborparts=" << cbor_valid << cbor_control_text << cbor_duplicate << cbor_invalid_utf8 << cbor_truncated << cbor_trailing << cbor_depth << cbor_nodes << cbor_size << cbor_indefinite << cbor_integer_overflow << cbor_nonminimal << "\\n"; + return(kid_ok && signed_ok && tamper_ok && negatives_ok && cose_ok && cbor_ok && cose_negative && b64_ok ? 0 : 1); } EOF -clang++ -std=c++20 -fpermissive -I. "$test_source" -lpcre2-8 -lcrypto -o "$test_binary" +"${CXX:-c++}" -std=c++20 -fpermissive -I. "$test_source" -lpcre2-8 -lcrypto -o "$test_binary" "$test_binary" echo "native structured crypto operation passed" diff --git a/scripts/test_hardened_http_native.cpp b/scripts/test_hardened_http_native.cpp new file mode 100644 index 0000000..c105bb8 --- /dev/null +++ b/scripts/test_hardened_http_native.cpp @@ -0,0 +1,37 @@ +#include "src/lib/types.cpp" +#include "src/lib/dvalue.cpp" +#include "src/lib/functionlib.cpp" +#include "src/wasm/hardened_http_internal.h" +#include +struct Fake { std::vector answers, argv, env; String input; HardenedHttpExecResult result; }; +static DValue request() { DValue r; r["method"]="POST"; r["url"]="https://api.example.test/token"; r["headers"]["Accept"]="application/json"; r["body"]="client_secret=SECRET"; DValue& s=r["security"]; s["https_only"].set_bool(true); s["public_dns_only"].set_bool(true); s["pin_dns"].set_bool(true); s["isolated_curl"].set_bool(true); s["no_redirects"].set_bool(true); return r; } +static DValue run(Fake& f,DValue r) { HardenedHttpHooks h; h.resolve=[&](String){return f.answers;}; h.execute=[&](std::vector a,String i,std::vector e,u64,size_t){f.argv=a;f.input=i;f.env=e;return f.result;}; return hardened_http_request_internal(r,9000,h); } +static String headers(int status=200) { return "HTTP/1.1 "+std::to_string(status)+" OK\r\nContent-Type: application/json\r\n\r\n"; } +static bool exists(String path) { return access(path.c_str(),F_OK)==0; } +static void wait_for(String path) { for(int n=0;n<500&&!exists(path);n++) usleep(1000); } +static bool process_live(pid_t pid) { char state=0; String path="/proc/"+std::to_string((long long)pid)+"/stat"; int fd=open(path.c_str(),O_RDONLY); if(fd<0) return false; char text[256]{}; ssize_t n=read(fd,text,sizeof(text)-1); close(fd); if(n<=0) return false; char* close_paren=strrchr(text,')'); return close_paren&&close_paren[2]!='Z'; } +int main(int argc,char** argv) { + if(argc==3&&String(argv[1])=="--child-timeout") { pid_t child=fork(); if(child==0) { usleep(100000); int fd=open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0600); if(fd>=0) { write(fd,"leaked",6); close(fd); } _exit(0); } for(;;) pause(); } + if(argc==2&&String(argv[1])=="--child-output") { String x(4096,'x'); for(int n=0;n<64;n++) write(1,x.data(),x.size()); return 0; } + if(argc==3&&String(argv[1])=="--child-fds") { int inherited=atoi(argv[2]); bool closed=fcntl(inherited,F_GETFD)==-1&&errno==EBADF; bool header=fcntl(3,F_GETFD)!=-1; write(1,closed&&header?"closed":"open",closed&&header?6:4); return closed&&header?0:1; } + if(argc==5&&String(argv[1])=="--child-job") { int ready=open(argv[3],O_WRONLY|O_CREAT|O_TRUNC,0600); if(ready>=0) { write(ready,"ready",5); close(ready); } pid_t child=fork(); if(child==0) { int pidfile=open(argv[4],O_WRONLY|O_CREAT|O_TRUNC,0600); if(pidfile>=0) { String pid=std::to_string((long long)getpid()); write(pidfile,pid.data(),pid.size()); close(pidfile); } usleep(150000); int marker=open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0600); if(marker>=0) { write(marker,"leaked",6); close(marker); } _exit(0); } for(;;) pause(); } + if(argc==3&&String(argv[1])=="--child-async-timeout") { setsid(); HardenedHttpExecResult result=hardened_http_exec_argv_capture({"/bin/sleep","1"},"",20,4096,false,false); if(result.timed_out&&result.exit_code==137) { int fd=open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0600); if(fd>=0) { write(fd,"typed",5); close(fd); } } return 0; } + if(argc==3&&String(argv[1])=="--child-async-output") { setsid(); HardenedHttpExecResult result=hardened_http_exec_argv_capture({"/proc/self/exe","--child-output"},"",1000,1024,false,false); if(result.output_limited&&result.exit_code==137) { int fd=open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0600); if(fd>=0) { write(fd,"typed",5); close(fd); } } return 0; } + bool ok=true; auto need=[&](bool x,const char* n){if(!x){std::cerr<2&&f.argv[0]=="/usr/bin/curl"&&f.argv[1]=="--disable","absolute curl disable"); need(f.env.size()==1&&f.env[0]=="PATH=/usr/bin:/bin","clean env"); need(std::find(f.argv.begin(),f.argv.end(),"SECRET")==f.argv.end()&&f.input.find("SECRET")!=String::npos,"secret stdin only"); need(std::find(f.argv.begin(),f.argv.end(),"--resolve")!=f.argv.end(),"pinned dns"); + Fake interim; interim.answers={"8.8.8.8"}; interim.result.exit_code=0; interim.result.headers_text="HTTP/1.1 100 Continue\r\n\r\n"+headers(201); interim.result.body_text="{}"; need(run(interim,request())["status"].to_u64()==201,"interim headers"); + for(String bad:{"127.0.0.1","10.0.0.1","169.254.1.1","192.168.1.1","::1","2001:db8::1"}) { Fake x; x.answers={"8.8.8.8",bad}; need(run(x,request())["error"].to_string()=="unsafe_dns","dns matrix"); } + for(auto pair:std::vector>{{302,headers(302)},{500,headers(500)},{200,"bad\r\n\r\n"}}) { Fake x; x.answers={"8.8.8.8"};x.result.exit_code=0;x.result.headers_text=pair.second;need(run(x,request())["error"].to_string()==(pair.first==302?"redirect_not_allowed":pair.first==500?"http_status":"malformed_output"),"status errors"); } + DValue bad=request(); bad["method"]="TRACE"; Fake x; need(run(x,bad)["error"].to_string()=="invalid_request"&&x.argv.empty(),"method validation"); bad=request();bad["headers"]["Host"]="evil";need(run(x,bad)["error"].to_string()=="invalid_request","header validation"); bad=request();bad["headers"]["Accept"]="ok\r\nInjected: x";need(run(x,bad)["error"].to_string()=="invalid_request","header CRLF validation");bad=request();bad["url"]="https://127.0.0.1/";need(run(x,bad)["error"].to_string()=="invalid_request","url validation");bad=request();bad["follow_redirects"].set_bool(true);need(run(x,bad)["error"].to_string()=="invalid_request","redirect composition"); + for(String key:{"https_only","public_dns_only","pin_dns","isolated_curl","no_redirects"}) { bad=request(); bad["security"].remove(key); need(run(x,bad)["error"].to_string()=="invalid_request","partial hardening fails closed"); bad=request(); bad["security"][key].set_bool(false); need(run(x,bad)["error"].to_string()=="invalid_request","false hardening fails closed"); bad=request(); bad["security"][key]="true"; need(run(x,bad)["error"].to_string()=="invalid_request","non-boolean hardening fails closed"); } + DValue legacy=request(); legacy["security"].clear(); legacy["security"]["unrelated"]="value"; need(!hardened_http_security_requested(legacy.key("security")),"unknown security object remains legacy"); + Fake large;large.answers={"8.8.8.8"};large.result.exit_code=0;large.result.headers_text=headers();large.result.body_text=String(65537,'x');need(run(large,request())["error"].to_string()=="response_too_large","body cap"); + String base="/tmp/hardened-http-"+std::to_string((long long)getpid()), marker=base+"-marker"; unlink(marker.c_str()); HardenedHttpExecResult timeout=hardened_http_exec_argv_capture({"/proc/self/exe","--child-timeout",marker},"",30,4096,false); usleep(150000); need(timeout.timed_out&&timeout.exit_code==137&&!exists(marker),"timeout kills descendants"); unlink(marker.c_str()); + HardenedHttpExecResult overflow=hardened_http_exec_argv_capture({"/proc/self/exe","--child-output"},"",1000,1024,false);need(overflow.output_limited&&overflow.body_text.size()<=1024,"output cap/reap"); + int inherited=open("/dev/null",O_RDONLY), high_inherited=fcntl(inherited,F_DUPFD,10); close(inherited); HardenedHttpExecResult fds=hardened_http_exec_argv_capture({"/proc/self/exe","--child-fds",std::to_string(high_inherited)},"",1000,4096,false); close(high_inherited); need(fds.exit_code==0&&fds.body_text=="closed","only stdio and header fd inherited"); + String ready=base+"-ready", descendant=base+"-descendant", sentinel=base+"-sentinel"; unlink(marker.c_str()); unlink(ready.c_str()); unlink(descendant.c_str()); unlink(sentinel.c_str()); pid_t worker=fork(); if(worker==0) { setsid(); hardened_http_exec_argv_capture({"/proc/self/exe","--child-job",marker,ready,descendant},"",5000,4096,false,false); _exit(0); } wait_for(ready); wait_for(descendant); pid_t unrelated=fork(); if(unrelated==0) { setsid(); usleep(150000); int fd=open(sentinel.c_str(),O_WRONLY|O_CREAT|O_TRUNC,0600); if(fd>=0) { write(fd,"alive",5); close(fd); } _exit(0); } pid_t descendant_pid=exists(descendant)?(pid_t)strtol([](String path){ int fd=open(path.c_str(),O_RDONLY); char text[32]{}; ssize_t n=fd<0?-1:read(fd,text,sizeof(text)-1); if(fd>=0) close(fd); return String(text,n>0?(size_t)n:0); }(descendant).c_str(),0,10):0; need(exists(ready)&&descendant_pid>0,"async worker and descendant started"); kill(-worker,SIGKILL); waitpid(worker,0,0); usleep(250000); waitpid(unrelated,0,0); need(!process_live(descendant_pid)&&!exists(marker)&&exists(sentinel),"async cancellation leaves no live descendant and kills only its worker group"); unlink(marker.c_str()); unlink(ready.c_str()); unlink(descendant.c_str()); unlink(sentinel.c_str()); + String async_timeout=base+"-async-timeout"; unlink(async_timeout.c_str()); pid_t timeout_worker=fork(); if(timeout_worker==0) { execl("/proc/self/exe","test_hardened_http_native","--child-async-timeout",async_timeout.c_str(),(char*)0); _exit(127); } int timeout_status=0; waitpid(timeout_worker,&timeout_status,0); need(WIFEXITED(timeout_status)&&WEXITSTATUS(timeout_status)==0&&exists(async_timeout),"async timeout records a typed terminal result before worker exit"); unlink(async_timeout.c_str()); + String async_output=base+"-async-output"; unlink(async_output.c_str()); pid_t output_worker=fork(); if(output_worker==0) { execl("/proc/self/exe","test_hardened_http_native","--child-async-output",async_output.c_str(),(char*)0); _exit(127); } int output_status=0; waitpid(output_worker,&output_status,0); need(WIFEXITED(output_status)&&WEXITSTATUS(output_status)==0&&exists(async_output),"async output cap records a typed terminal result before worker exit"); unlink(async_output.c_str()); + return ok?0:1; +} diff --git a/scripts/test_hardened_http_native.sh b/scripts/test_hardened_http_native.sh new file mode 100755 index 0000000..e43111d --- /dev/null +++ b/scripts/test_hardened_http_native.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +repo=$(cd "$(dirname "$0")/.." && pwd) +bin="${TMPDIR:-/tmp}/test_hardened_http_native.$$" +trap 'rm -f "$bin"' EXIT +compiler=$(command -v clang++ || command -v g++) +"$compiler" -std=c++20 -fpermissive -I"$repo" "$repo/scripts/test_hardened_http_native.cpp" -lpcre2-8 -o "$bin" +timeout --signal=TERM --kill-after=2s 15s "$bin" diff --git a/site/doc/pages/crypto_operation.txt b/site/doc/pages/crypto_operation.txt index f3e6a38..4204b74 100644 --- a/site/doc/pages/crypto_operation.txt +++ b/site/doc/pages/crypto_operation.txt @@ -8,7 +8,7 @@ return value : map with ok, bounded error code, and operation-specific output :content Runs one explicitly supported structured asymmetric cryptographic operation. The initial allowlist is `key_generate` with `ES256` and `jwt_sign` with `ES256`. Unknown operations and algorithms fail closed. -ES256 signing validates that `x`, `y`, and `d` form one P-256 key, forces the protected `alg` to `ES256`, and emits a compact JWT with a 64-byte JOSE signature. Requests, nesting, values, and output are bounded. Header and claims roots must be JSON objects containing valid UTF-8 without raw control bytes. +ES256 signing validates that `x`, `y`, and `d` form one P-256 key, forces the protected `alg` to `ES256`, and emits a compact JWT with a 64-byte JOSE signature. Requests are capped at 32 KiB; `cbor_decode` accepts at most 16 KiB decoded CBOR (21,846 canonical base64url characters), 256 nodes, and depth 16. CBOR is definite-length only, validates UTF-8 text, preserves typed map keys, rejects structurally duplicate keys and trailing bytes, and reports malformed CBOR as `invalid_cbor`. Header and claims roots must be JSON objects containing valid UTF-8 without raw control bytes. This function does not replace typed digest, HMAC, password, randomness, or constant-time comparison APIs. It exposes no raw signing, arbitrary curve/digest selection, encryption, or generic OpenSSL access. Keep returned private JWKs secret. diff --git a/site/doc/pages/http_request.txt b/site/doc/pages/http_request.txt index c7e3385..0fb52d4 100644 --- a/site/doc/pages/http_request.txt +++ b/site/doc/pages/http_request.txt @@ -1,8 +1,18 @@ # http_request -Performs a bounded outbound HTTP(S) request using the runtime `curl` binary. Request fields: `method`, `url`, `headers`, `body`, `timeout_ms`, `follow_redirects`. +Performs an outbound HTTP(S) request using the runtime `curl` binary. Existing request fields remain backward-compatible: `method`, `url`, `headers`, `body`, `timeout_ms`, and `follow_redirects`. -Returns `{ status, headers, body, error }`. `headers` is a name/value map. A missing `curl` binary returns a clear `error` string. +An absent `security` map, or a map containing none of these recognized keys, keeps the legacy request behavior. If a `security` map contains any recognized key, it selects hardening and **every** recognized field below must be explicitly boolean `true`; missing, partial, non-true, or `false` fields fail closed with `invalid_request`. + +- `https_only` rejects non-HTTPS URLs, IP-literal hosts, and URL userinfo. +- `public_dns_only` validates **every** DNS answer against the public IPv4 policy. IPv6 answers currently fail closed. +- `pin_dns` pins curl to one validated answer with `--resolve`, retaining the URL hostname for TLS/SNI. +- `isolated_curl` uses absolute `/usr/bin/curl`, `--disable` as argv[1], cleared environment, no proxy/config/netrc/HSTS/Alt-Svc inheritance, no redirects, a three-second connect bound, and a ten-second total bound. +- `no_redirects` makes redirect following and `follow_redirects=true` invalid composition. + +Hardened requests bound body input/output to 64 KiB and response headers to 8 KiB. Async hardened requests keep curl and its descendants in the job worker process group, so cancelling that job kills that group only. Methods are limited to `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, and `OPTIONS`; header names/values are validated and caller-controlled `Host`, framing, connection, and expectation headers are rejected. Sensitive request bodies go to curl stdin, never argv. Errors are typed non-secret values such as `invalid_request`, `unsafe_dns`, `timeout`, `response_too_large`, `redirect_not_allowed`, `http_status`, and `network_failure`. + +Returns `{ status, headers, body, error }`. :see >socket @@ -12,3 +22,18 @@ DValue req; req["method"] = "GET"; req["url"] = "http://127.0.0.1/doc/index.uce" req["headers"]["Host"] = "uce.openfu.com"; req["timeout_ms"] = (f64)2000; DValue resp = http_request(req); print("HTTP ", resp["status"].to_u64(), ", ", resp["body"].to_string().length(), " bytes returned\n"); + +:example +// GitHub token exchange: client_secret stays in stdin body, not argv. +DValue token; token["method"]="POST"; token["url"]="https://github.com/login/oauth/access_token"; +token["headers"]["Accept"]="application/json"; token["headers"]["Content-Type"]="application/x-www-form-urlencoded"; +token["body"]="client_id="+uri_encode(client_id)+"&client_secret="+uri_encode(client_secret)+"&code="+uri_encode(code); +for(String key:{"https_only","public_dns_only","pin_dns","isolated_curl","no_redirects"}) token["security"][key].set_bool(true); +DValue token_response=http_request(token); + +:example +// ATProto metadata discovery uses the same generic policy; no provider operation name. +DValue meta; meta["method"]="GET"; meta["url"]="https://"+issuer_host+"/.well-known/oauth-authorization-server"; +meta["headers"]["Accept"]="application/json"; +for(String key:{"https_only","public_dns_only","pin_dns","isolated_curl","no_redirects"}) meta["security"][key].set_bool(true); +DValue metadata=http_request(meta); diff --git a/site/doc/pages/http_request_async.txt b/site/doc/pages/http_request_async.txt index c2a4e6a..4a91177 100644 --- a/site/doc/pages/http_request_async.txt +++ b/site/doc/pages/http_request_async.txt @@ -1,6 +1,6 @@ # http_request_async -Starts the same bounded curl-backed request as `http_request()` in the file-backed async job registry and returns a job id. Use `job_await()` or `job_result()` to retrieve the HTTP result. +Starts the same request shape as `http_request()`, including its opt-in `security` hardening fields, in the file-backed async job registry and returns a job id. An unknown `security` object remains legacy; any recognized hardening key requires all five keys to be explicit `true` or the request fails closed. Hardened curl remains in this job's worker process group, so `job_cancel()` kills curl and its descendants without signalling other jobs. Use `job_await()` or `job_result()` to retrieve the HTTP result. :see >socket diff --git a/site/doc/pages/mysql_query.txt b/site/doc/pages/mysql_query.txt index 27d86ae..768dda9 100644 --- a/site/doc/pages/mysql_query.txt +++ b/site/doc/pages/mysql_query.txt @@ -16,6 +16,8 @@ Executes a MySQL query and returns the resulting data, if any. `params` provides the query parameter values used by the statement. Use named `:name` placeholders only; positional `?` placeholders are rejected. +Ordinary placeholders are escaped and quoted as SQL string values. For grammar positions that require an unquoted non-negative integer, such as `LIMIT` and `OFFSET`, append `!` to the placeholder (`:limit!`). Unsigned placeholders fail before query execution unless their value is a non-empty sequence of decimal digits. They never accept signs, whitespace, expressions, identifiers, or other SQL fragments. + The result is returned as a `DValue`, which makes it easy to iterate through rows and read fields with the usual `DValue` accessors. After an insert, update, or delete, use `mysql_affected_rows()` to inspect how many rows changed. @@ -24,7 +26,8 @@ After an insert, update, or delete, use `mysql_affected_rows()` to inspect how m MySQL* db = mysql_connect(); if(db != 0) { - DValue rows = mysql_query(db, "select 'ada@example.test' as email, 1 + 1 as total"); + StringMap params; params["limit"] = "1"; + DValue rows = mysql_query(db, "select 'ada@example.test' as email, 1 + 1 as total limit :limit!", params); String email = "none"; String total = "?"; rows.each([&](DValue r, String key) { email = r["email"].to_string(); total = r["total"].to_string(); }); print(email, " / total=", total, "\n"); diff --git a/site/doc/pages/task_repeat.txt b/site/doc/pages/task_repeat.txt index cb0eca6..ee279c3 100644 --- a/site/doc/pages/task_repeat.txt +++ b/site/doc/pages/task_repeat.txt @@ -21,7 +21,7 @@ Starts a repeating background worker process. If a process with the same `key` is already running anywhere in the runtime instance, `task_repeat()` does not start a second worker and instead returns the PID of the existing one. Coordination is through the same shared task state used by `task()`. -`timeout` bounds the lifetime of the repeating worker. The default is ten minutes. Pass `0` only for workers that have another shutdown path. +`timeout` bounds the lifetime of the repeating worker. The default is ten minutes. Pass `0` only for workers that have another shutdown path. In Wasm, each individual callback invocation remains bounded by the runtime invocation timeout even when the repeating process lifetime is unbounded. :example task_repeat("doc-demo-repeat", 60.0, []() { usleep(10000); }); diff --git a/site/tests/cli.uce b/site/tests/cli.uce index b5254be..1c05b67 100644 --- a/site/tests/cli.uce +++ b/site/tests/cli.uce @@ -20,6 +20,37 @@ CLI(Request& context) print(cli_arg(context, "missing", "fallback"), "\n"); return; } + if(action == "task_repeat_unbounded") + { + String marker = "/tmp/uce-task-repeat-unbounded.txt"; + file_unlink(marker); + pid_t pid = task_repeat("uce-task-repeat-unbounded", 0.05, [marker]() { file_put_contents(marker, "ran"); }, 0); + usleep(200000); + bool ran = file_get_contents(marker) == "ran"; + if(pid > 0) task_kill(pid, 15); + file_unlink(marker); + print(pid > 0 && ran ? "task repeat unbounded ok\n" : "task repeat unbounded failed\n"); + return; + } + if(action == "mysql_unsigned_params") + { + MySQL db; + StringMap params; params["limit"] = "1"; + String parsed = db.parse_query_parameters("SELECT 7 LIMIT :limit!", params); + StringMap comparison; comparison["left"] = "7"; comparison["right"] = "8"; + String inequality = db.parse_query_parameters("SELECT :left!=:right", comparison); + StringMap empty_name; empty_name[""] = "1"; + db.parameter_error = false; db.statement_info = ""; + db.parse_query_parameters("SELECT 1 LIMIT :!", empty_name); + bool empty_rejected = db.parameter_error; + StringMap invalid; invalid["limit"] = "1;SELECT 9"; + db.parameter_error = false; + db.statement_info = ""; + db.parse_query_parameters("SELECT 1 LIMIT :limit!", invalid); + bool ok = parsed.find("LIMIT 1") != String::npos && inequality.find("'7'!='8'") != String::npos && empty_rejected && db.parameter_error && db.statement_info.find("must contain only decimal digits") != String::npos; + print(ok ? "mysql unsigned params ok\n" : "mysql unsigned params failed: parsed=" + parsed + ", parameter_error=" + (db.parameter_error ? "1" : "0") + ", statement=" + db.statement_info + "\n"); + return; + } context.set_status(400, "CLI Error"); print("unknown cli action: ", action, "\n"); } diff --git a/site/tests/core.uce b/site/tests/core.uce index 89d103c..40a32af 100644 --- a/site/tests/core.uce +++ b/site/tests/core.uce @@ -31,7 +31,7 @@ RENDER(Request& context) StringMap colon_uri_headers = split_http_headers("GET /clock.uce?t=12:30 HTTP/1.1\r\nHost: colon.example\r\n"); check("split_http_headers() request line with colon in URI", colon_uri_headers["REQUEST_METHOD"] == "GET" && colon_uri_headers["DOCUMENT_URI"] == "/clock.uce" && colon_uri_headers["QUERY_STRING"] == "t=12:30" && colon_uri_headers["HTTP_HOST"] == "colon.example", var_dump(colon_uri_headers)); check("trim() / split_kv() / split_http_headers()", trim(" padded value ") == "padded value" && kv["alpha"] == "one" && kv["empty"] == "" && http_headers["REQUEST_METHOD"] == "GET" && http_headers["DOCUMENT_URI"] == "/demo.uce" && http_headers["QUERY_STRING"] == "x=1" && http_headers["HTTP_X_EMPTY"] == "" && leading_crlf_headers["REQUEST_METHOD"] == "GET" && leading_crlf_headers["DOCUMENT_URI"] == "/lead.uce" && leading_crlf_headers["HTTP_HOST"] == "lead.example" && header_only["REQUEST_METHOD"] == "" && header_only["HTTP_HOST"] == "example.test" && header_only["HTTP_X_TOKEN"] == "abc", trim(" padded value ") + " / " + var_dump(kv) + " / " + var_dump(http_headers) + " / " + var_dump(leading_crlf_headers) + " / " + var_dump(header_only)); - check("replace()", replace("alpha-beta-beta", "beta", "done") == "alpha-done-done", replace("alpha-beta-beta", "beta", "done")); + check("replace()", replace("alpha-beta-beta", "beta", "done") == "alpha-done-done" && replace("hello", "", "X") == "hello", replace("alpha-beta-beta", "beta", "done")); check("html_escape() attribute-safe quotes", html_escape("<&>\"Don't") == "<&>"Don't", html_escape("<&>\"Don't")); check("regex_match()", regex_match("[A-Z][a-z]+", "Alice") && !regex_match("[A-Z][a-z]+", "Alice!"), "full-string validation"); @@ -212,11 +212,19 @@ RENDER(Request& context) String decoded_base64 = base64_decode(encoded_base64, base64_ok); bool invalid_base64_ok = true; base64_decode("AA=A", invalid_base64_ok); - check("base64_encode() / base64_decode() binary-safe", base64_ok && decoded_base64 == binary_payload && decoded_base64.size() == binary_payload.size() && !invalid_base64_ok, encoded_base64 + " bytes=" + std::to_string((u64)decoded_base64.size())); + bool early_padding_base64_ok = true; + base64_decode("A=AA", early_padding_base64_ok); + check("base64_encode() / base64_decode() binary-safe", base64_ok && decoded_base64 == binary_payload && decoded_base64.size() == binary_payload.size() && !invalid_base64_ok && !early_padding_base64_ok, encoded_base64 + " bytes=" + std::to_string((u64)decoded_base64.size())); String utf8_sample = "A\xC3\xA9"; auto utf8_parts = split_utf8(utf8_sample); - check("split_utf8()", utf8_parts.size() == 2, "count=" + std::to_string(utf8_parts.size())); + String truncated_utf8 = "abc"; + truncated_utf8.push_back((char)0xE2); + truncated_utf8.push_back((char)0x82); + auto truncated_utf8_parts = split_utf8(truncated_utf8); + String lone_utf8_lead(1, (char)0xF0); + auto compound_lone_utf8_parts = split_utf8(lone_utf8_lead, true); + check("split_utf8()", utf8_parts.size() == 2 && truncated_utf8_parts.size() == 4 && truncated_utf8_parts[3].size() == 2 && compound_lone_utf8_parts.size() == 1 && compound_lone_utf8_parts[0] == lone_utf8_lead, "count=" + std::to_string(utf8_parts.size()) + " truncated=" + std::to_string(truncated_utf8_parts.size())); DValue payload; payload["name"] = "uce"; diff --git a/site/tests/crypto_operation.uce b/site/tests/crypto_operation.uce index 485e923..108105b 100644 --- a/site/tests/crypto_operation.uce +++ b/site/tests/crypto_operation.uce @@ -41,6 +41,17 @@ RENDER(Request& context) wrong_request["private_jwk"] = wrong_curve; DValue malformed_request = sign_request; malformed_request["private_jwk"] = malformed; + DValue cose_request; + cose_request["operation"] = "cose_es256_parse"; + cose_request["algorithm"] = "ES256"; + cose_request["cose_key_base64url"] = "pQECAyYgASFYIGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWIlggT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU"; + DValue cose = crypto_operation(cose_request); + DValue verify_request; + verify_request["operation"] = "es256_verify"; + verify_request["algorithm"] = "ES256"; + verify_request["cose_key_base64url"] = cose_request["cose_key_base64url"]; + verify_request["message_base64url"] = "d2ViYXV0aG4gZml4ZWQgbWVzc2FnZQ"; + verify_request["signature_der_base64url"] = "MEUCIQCkatZK1VVsjk17uvyzyhjdAkMNWXPjxSOMqWcjmM_8XAIgDaSk3Qufyd0_6r9Dm9A8RQbFco-FdTBulq7bvRGoBC4"; DValue unsupported; unsupported["operation"] = "encrypt"; unsupported["algorithm"] = "ES256"; @@ -53,9 +64,12 @@ RENDER(Request& context) list_request["protected_header"] = list_header; DValue control_request = sign_request; control_request["claims"]["bad"] = String("control\nbyte"); - DValue oversized = key_request; - oversized["ignored"] = String(17000, 'x'); - check("crypto_operation() ES256 key generation and JWT signing", key["ok"].to_bool() && signed_result["ok"].to_bool() && key["private_jwk"]["kty"].to_string() == "EC" && key["private_jwk"]["crv"].to_string() == "P-256" && key["public_jwk"]["d"].to_string() == "" && key["kid"].to_string() == key["thumbprint"].to_string() && parts.size() == 3 && parts[2].size() == 86 && decoded_header.find("ES256") != String::npos && !crypto_operation(wrong_request)["ok"].to_bool() && !crypto_operation(malformed_request)["ok"].to_bool() && crypto_operation(unsupported)["error"].to_string() == "unsupported_operation" && !crypto_operation(list_request)["ok"].to_bool() && crypto_operation(control_request)["error"].to_string() == "invalid_request" && crypto_operation(oversized)["error"].to_string() == "invalid_request", key["kid"].to_string()); + check("crypto_operation() generates an ES256 key", key["ok"].to_bool() && key["private_jwk"]["kty"].to_string() == "EC" && key["private_jwk"]["crv"].to_string() == "P-256" && key["public_jwk"]["d"].to_string() == "" && key["kid"].to_string() == key["thumbprint"].to_string(), key["kid"].to_string()); + check("crypto_operation() signs ES256 JWTs", signed_result["ok"].to_bool() && parts.size() == 3 && parts[2].size() == 86 && decoded_header.find("ES256") != String::npos, signed_result["error"].to_string()); + check("crypto_operation() rejects invalid signing requests", !crypto_operation(wrong_request)["ok"].to_bool() && !crypto_operation(malformed_request)["ok"].to_bool() && !crypto_operation(list_request)["ok"].to_bool() && crypto_operation(control_request)["error"].to_string() == "invalid_request", "signing validation"); + check("crypto_operation() parses an ES256 COSE key", cose["ok"].to_bool(), cose["error"].to_string()); + check("crypto_operation() verifies ES256 DER signatures", crypto_operation(verify_request)["ok"].to_bool() && crypto_operation(verify_request)["valid"].to_bool(), crypto_operation(verify_request)["error"].to_string()); + check("crypto_operation() rejects unsupported operations", crypto_operation(unsupported)["error"].to_string() == "unsupported_operation", crypto_operation(unsupported)["error"].to_string()); site_tests_summary(passed, failed, skipped, "Structured crypto tests generate ephemeral P-256 keys and retain no key material."); site_tests_page_end(); } diff --git a/site/tests/preprocessor.uce b/site/tests/preprocessor.uce index ade12be..25d4e30 100644 --- a/site/tests/preprocessor.uce +++ b/site/tests/preprocessor.uce @@ -31,6 +31,8 @@ RENDER(Request& context) }; String nested = preprocessor_nested_literal(); + String trailing_backslash = "C:\\Users\\"; + String escaped_quote = "before\"after"; site_tests_page_start("Preprocessor", "Regression coverage for literal output rewriting and parser edge cases."); ?> @@ -43,6 +45,8 @@ RENDER(Request& context) check("raw string terminator in nested literal", contains(nested, "nested )\" marker"), nested); check("entrypoint @fragment attribute captures output", context.call["fragments"]["preprocessor-test"].to_string() == "fragment attr once", context.call["fragments"]["preprocessor-test"].to_string()); check("inline code island after dangerous literal", true, "parser returned to C++ after rendering literal content containing )\""); + check("quote scanner handles trailing escaped backslash", trailing_backslash == "C:\\Users\\", trailing_backslash); + check("quote scanner retains escaped quote content", escaped_quote == "before\"after", escaped_quote); site_tests_summary(passed, failed, skipped, "Literal content containing the C++ raw-string terminator sequence must compile and render unchanged."); site_tests_page_end(); diff --git a/site/tests/services.uce b/site/tests/services.uce index ad3b132..5b71ff6 100644 --- a/site/tests/services.uce +++ b/site/tests/services.uce @@ -28,14 +28,20 @@ RENDER(Request& context) u64 sockfd = socket_connect("127.0.0.1", 80); if(sockfd != 0) { + u64 closed_handle = socket_connect("127.0.0.1", 80); + bool opaque_handles = sockfd == 1 && closed_handle == 2; + socket_close(closed_handle); + socket_close(closed_handle); + socket_close(999999); + bool closed_write_rejected = !socket_write(closed_handle, "invalid"); bool write_ok = socket_write(sockfd, "GET /tests/index.uce HTTP/1.0\r\nHost: uce.openfu.com\r\n\r\n"); String response = socket_read(sockfd, 4096, 2); socket_close(sockfd); bool has_nul = response.find(String("\0", 1)) != String::npos; mark( - "socket_connect() / socket_write() / socket_read()", - (write_ok && response.find("200 OK") != String::npos && !has_nul) ? "pass" : "fail", - response.substr(0, response.length() > 220 ? 220 : response.length()) + (has_nul ? " [unexpected NUL]" : "") + "socket handles reject invalid and closed indices", + (opaque_handles && closed_write_rejected && write_ok && response.find("200 OK") != String::npos && !has_nul) ? "pass" : "fail", + "handles=" + std::to_string(sockfd) + "/" + std::to_string(closed_handle) + " " + response.substr(0, response.length() > 180 ? 180 : response.length()) + (has_nul ? " [unexpected NUL]" : "") ); } else @@ -130,6 +136,15 @@ RENDER(Request& context) parsed_underscore.find("'Ada'") != String::npos && parsed_underscore.find("_name") == String::npos ? "pass" : "fail", parsed_underscore ); + StringMap quoted_params; + quoted_params["name_looking_text"] = "SUBSTITUTED"; + String escaped_quote_query = "SELECT 'a\\':name_looking_text'"; + String parsed_escaped_quote = placeholder_guard.parse_query_parameters(escaped_quote_query, quoted_params); + mark( + "mysql named placeholders stay opaque after escaped quotes", + parsed_escaped_quote.find(":name_looking_text") != String::npos && parsed_escaped_quote.find("SUBSTITUTED") == String::npos ? "pass" : "fail", + parsed_escaped_quote + ); MySQL unavailable_mysql; unavailable_mysql.connect("127.0.0.1", "__uce_intentionally_missing__", "not-a-real-password"); mark( @@ -173,6 +188,14 @@ RENDER(Request& context) inserted == 2 && updated == 1 && selected == 0 ? "pass" : "fail", "inserted=" + std::to_string((u64)inserted) + " updated=" + std::to_string((u64)updated) + " selected=" + std::to_string((u64)selected) ); + mysql.query("INSERT INTO uce_affected_rows_test VALUES (1,30)"); + u32 duplicate_error_code = mysql._preload_next_error_code; + String duplicate_error = mysql.error(); + mark( + "mysql query failures retain server diagnostics", + duplicate_error_code > 1 && duplicate_error != "" && duplicate_error != "Unknown server error" ? "pass" : "fail", + "code=" + std::to_string((u64)duplicate_error_code) + " error=" + duplicate_error + ); } DValue mysql_perf = request_perf(); String mysql_operations = json_encode(mysql_perf["mysql_operations"]); diff --git a/site/tests/units.uce b/site/tests/units.uce index 9fd7f50..ff38a80 100644 --- a/site/tests/units.uce +++ b/site/tests/units.uce @@ -18,6 +18,10 @@ RENDER(Request& context) auto unit_paths = units_list(); DValue info = unit_info("call_helpers.uce"); DValue relative_info = unit_info("components/../relative-child.uce"); + String outside_unit = "/tmp/uce-site-tests-outside-unit.uce"; + file_put_contents(outside_unit, "RENDER(Request& context) {}"); + DValue outside_info = unit_info(outside_unit); + file_unlink(outside_unit); ob_start(); unit_call("call_helpers.uce", "emit_marker"); @@ -32,6 +36,7 @@ RENDER(Request& context) check("units_list()", unit_paths.size() > 0, "count=" + std::to_string(unit_paths.size())); check("unit_info()", info["path"].to_string() != "", json_encode(info)); check("compiler canonicalizes relative unit paths", relative_info["path"].to_string().find("/../") == String::npos && str_ends_with(relative_info["path"].to_string(), "/site/tests/relative-child.uce"), json_encode(relative_info)); + check("compiler rejects units outside document root", outside_info["path"].to_string() == "", json_encode(outside_info)); check("unit_compile()", unit_compile("call_helpers.uce"), "call_helpers.uce"); check("unit_call()", call_output.find("UNIT_CALL_EXPORT_OK") != String::npos, call_output); check("unit_render()", render_output.find("data-unit-render=\"ok\"") != String::npos, render_output); diff --git a/src/lib/compiler-parser.cpp b/src/lib/compiler-parser.cpp index 32fb484..08179bb 100644 --- a/src/lib/compiler-parser.cpp +++ b/src/lib/compiler-parser.cpp @@ -74,8 +74,14 @@ void compiler_code_state_consume(CompilerCodeState& state, String& buffer, const if(state.inside_quote) { - if(state.quote_char == c && (i == 0 || content[i-1] != '\\')) - state.inside_quote = false; + if(state.quote_char == c) + { + u32 backslashes = 0; + for(u32 j = i; j > 0 && content[j - 1] == '\\'; j -= 1) + backslashes += 1; + if(backslashes % 2 == 0) + state.inside_quote = false; + } return; } diff --git a/src/lib/compiler.cpp b/src/lib/compiler.cpp index 67c9033..c9a2317 100644 --- a/src/lib/compiler.cpp +++ b/src/lib/compiler.cpp @@ -646,7 +646,10 @@ int compiler_open_lock_file(String file_name, String purpose, bool nonblocking = (void)purpose; auto lock_dir = dirname(file_name); if(lock_dir != "") - mkdir(lock_dir); + { + std::error_code error; + std::filesystem::create_directories(lock_dir, error); + } int fdlock = open(file_name.c_str(), O_RDWR | O_CREAT, 0666); if(fdlock == -1 && (errno == EACCES || errno == EPERM)) fdlock = open(file_name.c_str(), O_RDONLY | O_CLOEXEC); @@ -676,7 +679,10 @@ int compiler_open_lock_file_bounded(String file_name, String purpose, CompilerDe return(compiler_open_lock_file(file_name, purpose)); auto lock_dir = dirname(file_name); if(lock_dir != "") - mkdir(lock_dir); + { + std::error_code error; + std::filesystem::create_directories(lock_dir, error); + } int fdlock = open(file_name.c_str(), O_RDWR | O_CREAT, 0666); if(fdlock == -1 && (errno == EACCES || errno == EPERM)) fdlock = open(file_name.c_str(), O_RDONLY | O_CLOEXEC); @@ -734,14 +740,34 @@ static void compiler_mark_source_generation_nonblocking(Request* context) String compiler_normalize_unit_path(Request* context, String file_name) { file_name = trim(file_name); - if(file_name == "") + if(file_name == "" || !context || !context->server) return(""); if(file_name[0] != '/') file_name = expand_path(file_name, context->server->config["COMPILER_SYS_PATH"]); String canonical = path_real(file_name); - if(canonical != "") - return(canonical); - return(file_name); + if(canonical == "") + { + std::error_code error; + canonical = std::filesystem::weakly_canonical(file_name, error).string(); + if(error || canonical == "") + return(""); + } + String allowed_root = trim(first( + context->params["DOCUMENT_ROOT"], + context->server->config["PRECOMPILE_FILES_IN"], + context->server->config["HTTP_DOCUMENT_ROOT"], + path_join(context->server->config["COMPILER_SYS_PATH"], context->server->config["SITE_DIRECTORY"]) + )); + if(allowed_root == "") + return(""); + if(allowed_root[0] != '/') + allowed_root = expand_path(allowed_root, context->server->config["COMPILER_SYS_PATH"]); + allowed_root = path_real(allowed_root); + if(allowed_root == "") + return(""); + if(allowed_root[allowed_root.length() - 1] != '/') + allowed_root += "/"; + return(canonical + "/" == allowed_root || str_starts_with(canonical, allowed_root) ? canonical : ""); } bool compiler_is_known_unit_file(String file_name) diff --git a/src/lib/functionlib.cpp b/src/lib/functionlib.cpp index aea7d0e..da659ac 100644 --- a/src/lib/functionlib.cpp +++ b/src/lib/functionlib.cpp @@ -140,6 +140,8 @@ bool contains(String haystack, String needle) String replace(String s, String search, String replace_with) { + if(search == "") + return(s); s64 last_spos = 0; auto spos = s.find(search); if(spos == std::string::npos) @@ -791,7 +793,7 @@ DValue array_merge(DValue a, DValue b) StringList split_utf8(String s, bool compound_characters) { StringList result; - auto len = s.size(); + s64 len = (s64)s.size(); String codepoint = ""; for(s64 i = 0; i < len; i++) { @@ -800,13 +802,13 @@ StringList split_utf8(String s, bool compound_characters) { codepoint = ""; codepoint.append(1, c); - if(is_bit_set(c, 6)) + if(is_bit_set(c, 6) && i + 1 < len) { codepoint.append(1, s[++i]); - if(is_bit_set(c, 5)) + if(is_bit_set(c, 5) && i + 1 < len) { codepoint.append(1, s[++i]); - if(is_bit_set(c, 4)) + if(is_bit_set(c, 4) && i + 1 < len) { codepoint.append(1, s[++i]); } @@ -837,7 +839,7 @@ StringList split_utf8(String s, bool compound_characters) join_next = true; last_was_regional = false; } - else if(s[0] == '\xF0' && s[1] == '\x9F' && s[2] == '\x87' && s[3] >= '\xA6' && s[3] <= '\xBF') // Regional indicator letters + else if(s.size() == 4 && s[0] == '\xF0' && s[1] == '\x9F' && s[2] == '\x87' && s[3] >= '\xA6' && s[3] <= '\xBF') // Regional indicator letters { if(last_was_regional) { @@ -850,7 +852,7 @@ StringList split_utf8(String s, bool compound_characters) last_was_regional = true; } } - else if(s[0] == '\xEF' && s[1] == '\xB8' && s[2] >= '\x80' && s[2] <= '\x8F') // Variation selector + else if(s.size() == 3 && s[0] == '\xEF' && s[1] == '\xB8' && s[2] >= '\x80' && s[2] <= '\x8F') // Variation selector { compound_result[compound_result.size()-1] += s; last_was_regional = false; diff --git a/src/lib/hash.cpp b/src/lib/hash.cpp index 81e6a4b..e5c8dc2 100644 --- a/src/lib/hash.cpp +++ b/src/lib/hash.cpp @@ -423,12 +423,11 @@ bool crypto_equal_native(String a, String b) return(diff == 0); } -static bool uce_crypto_utf8_json_string(String value) +static bool uce_crypto_utf8_string(String value) { for(size_t i = 0; i < value.size();) { u8 c = (u8)value[i]; - if(c < 0x20) return(false); if(c < 0x80) { i++; continue; } size_t need = c >= 0xC2 && c <= 0xDF ? 1 : (c >= 0xE0 && c <= 0xEF ? 2 : (c >= 0xF0 && c <= 0xF4 ? 3 : 0)); if(need == 0 || i + need >= value.size()) return(false); @@ -440,6 +439,13 @@ static bool uce_crypto_utf8_json_string(String value) return(true); } +static bool uce_crypto_utf8_json_string(String value) +{ + if(!uce_crypto_utf8_string(value)) return(false); + for(unsigned char c : value) if(c < 0x20) return(false); + return(true); +} + static bool uce_crypto_value_valid(const DValue& value, size_t depth, size_t& nodes, size_t& bytes) { if(depth > 16 || ++nodes > 256) return(false); @@ -447,7 +453,7 @@ static bool uce_crypto_value_valid(const DValue& value, size_t depth, size_t& no if(item.type == 'S') { bytes += item._String.size(); - return(bytes <= 16384 && uce_crypto_utf8_json_string(item._String)); + return(bytes <= 32768 && uce_crypto_utf8_json_string(item._String)); } if(item.type == 'F') return(std::isfinite(item._float)); if(item.type == 'B') return(true); @@ -458,7 +464,7 @@ static bool uce_crypto_value_valid(const DValue& value, size_t depth, size_t& no if(!list) { bytes += child.first.size(); - if(bytes > 16384 || !uce_crypto_utf8_json_string(child.first)) return(false); + if(bytes > 32768 || !uce_crypto_utf8_json_string(child.first)) return(false); } if(!uce_crypto_value_valid(child.second, depth + 1, nodes, bytes)) return(false); } @@ -588,6 +594,11 @@ static constexpr size_t UCE_ES256_SIGNATURE_BYTES = 64; static constexpr size_t UCE_ES256_JSON_MAX = 16 * 1024; static constexpr size_t UCE_ES256_VALUE_MAX = 256; static constexpr size_t UCE_ES256_DEPTH_MAX = 16; +static constexpr size_t UCE_ES256_COORDINATE_BASE64URL_MAX = 43; +static constexpr size_t UCE_CBOR_MAX_BYTES = 16 * 1024; +static constexpr size_t UCE_CBOR_MAX_BASE64URL = 21846; +static constexpr size_t UCE_ES256_DER_MAX_BYTES = 144; +static constexpr size_t UCE_ES256_DER_BASE64URL_MAX = 192; struct UcePkeyDeleter { void operator()(EVP_PKEY* value) const { EVP_PKEY_free(value); } }; struct UcePkeyCtxDeleter { void operator()(EVP_PKEY_CTX* value) const { EVP_PKEY_CTX_free(value); } }; @@ -604,9 +615,9 @@ static String uce_base64url_encode(const unsigned char* bytes, size_t size) return(encoded); } -static bool uce_base64url_decode(String encoded, String& decoded) +static bool uce_base64url_decode(String encoded, String& decoded, size_t max_encoded) { - if(encoded.empty() || encoded.size() > 128 || encoded.find('=') != String::npos || encoded.size() % 4 == 1) + if(encoded.empty() || encoded.size() > max_encoded || encoded.find('=') != String::npos || encoded.size() % 4 == 1) return(false); for(char c : encoded) if(!(c >= 'A' && c <= 'Z') && !(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-' && c != '_') @@ -651,7 +662,7 @@ static bool uce_es256_jwk_string(const DValue& jwk, const String& field, String& if(!found) return(false); const DValue& item = found->deref(); - if(item.type != 'S' || item._String.empty() || item._String.size() > 128) + if(item.type != 'S' || item._String.empty() || item._String.size() > 32768) return(false); value = item._String; return(true); @@ -663,7 +674,7 @@ static std::unique_ptr uce_es256_key_from_jwk(const DV return(nullptr); String kty, crv, x64, y64, d64, x, y, d; if(!uce_es256_jwk_string(jwk, "kty", kty) || !uce_es256_jwk_string(jwk, "crv", crv) || !uce_es256_jwk_string(jwk, "x", x64) || !uce_es256_jwk_string(jwk, "y", y64) || !uce_es256_jwk_string(jwk, "d", d64) || - kty != "EC" || crv != "P-256" || !uce_base64url_decode(x64, x) || !uce_base64url_decode(y64, y) || !uce_base64url_decode(d64, d) || + kty != "EC" || crv != "P-256" || !uce_base64url_decode(x64, x, UCE_ES256_COORDINATE_BASE64URL_MAX) || !uce_base64url_decode(y64, y, UCE_ES256_COORDINATE_BASE64URL_MAX) || !uce_base64url_decode(d64, d, UCE_ES256_COORDINATE_BASE64URL_MAX) || x.size() != UCE_ES256_COORDINATE_BYTES || y.size() != UCE_ES256_COORDINATE_BYTES || d.size() != UCE_ES256_COORDINATE_BYTES) return(nullptr); unsigned char public_key[65]; @@ -701,6 +712,87 @@ static bool uce_es256_key_coordinates(EVP_PKEY* key, String& x, String& y, Strin return(true); } +enum class UceCborKind { Unsigned, Negative, Bytes, Text, Array, Map }; +struct UceCbor { UceCborKind kind; u64 number = 0; String bytes; std::vector items; }; +static constexpr size_t UCE_CBOR_MAX_NODES = 256, UCE_CBOR_MAX_DEPTH = 16; + +static bool uce_cbor_uint(const String& in, size_t& p, u8 ai, u64& n) +{ + if(ai < 24) { n = ai; return(true); } + size_t count = ai == 24 ? 1 : ai == 25 ? 2 : ai == 26 ? 4 : ai == 27 ? 8 : 0; + if(!count || p > in.size() || count > in.size() - p) return(false); + n = 0; + for(size_t i = 0; i < count; i++) + { + if(n > (UINT64_MAX - (u8)in[p + i]) / 256) return(false); + n = n * 256 + (u8)in[p + i]; + } + p += count; + return((count == 1 && n >= 24) || (count == 2 && n > 0xff) || (count == 4 && n > 0xffff) || (count == 8 && n > 0xffffffff)); +} + +static bool uce_cbor_equal(const UceCbor& left, const UceCbor& right) +{ + if(left.kind != right.kind || left.number != right.number || left.bytes != right.bytes || left.items.size() != right.items.size()) return(false); + for(size_t i = 0; i < left.items.size(); i++) if(!uce_cbor_equal(left.items[i], right.items[i])) return(false); + return(true); +} + +static bool uce_cbor_read(const String& in, size_t& p, UceCbor& out, size_t depth, size_t& nodes) +{ + if(p >= in.size() || depth > UCE_CBOR_MAX_DEPTH || ++nodes > UCE_CBOR_MAX_NODES) return(false); + u8 header = (u8)in[p++], major = header >> 5, ai = header & 31; + u64 n = 0; + if(ai == 31 || !uce_cbor_uint(in, p, ai, n)) return(false); + if(major == 0 || major == 1) + { + out.kind = major ? UceCborKind::Negative : UceCborKind::Unsigned; + out.number = n; + return(true); + } + if(major == 2 || major == 3) + { + if(n > UCE_CBOR_MAX_BYTES || n > in.size() - p || (major == 3 && !uce_crypto_utf8_string(String(in.data() + p, (size_t)n)))) return(false); + out.kind = major == 2 ? UceCborKind::Bytes : UceCborKind::Text; + out.bytes.assign(in.data() + p, (size_t)n); + p += (size_t)n; + return(true); + } + if((major != 4 && major != 5) || n > UCE_CBOR_MAX_NODES) return(false); + size_t item_count = (size_t)n * (major == 5 ? 2 : 1); + if(item_count > UCE_CBOR_MAX_NODES - nodes) return(false); + out.kind = major == 4 ? UceCborKind::Array : UceCborKind::Map; + out.items.reserve(item_count); + for(size_t i = 0; i < item_count; i++) + { + UceCbor child; + if(!uce_cbor_read(in, p, child, depth + 1, nodes)) return(false); + if(major == 5 && !(i & 1)) + for(size_t previous = 0; previous < out.items.size(); previous += 2) + if(uce_cbor_equal(out.items[previous], child)) return(false); + out.items.push_back(std::move(child)); + } + return(true); +} +static DValue uce_cbor_value(const UceCbor& value) +{ + DValue out; switch(value.kind) { case UceCborKind::Unsigned: out["type"]="unsigned"; out["value"]=std::to_string(value.number); break; case UceCborKind::Negative: out["type"]="negative"; out["value"]=value.number==UINT64_MAX?"-18446744073709551616":"-"+std::to_string(value.number+1); break; case UceCborKind::Bytes: out["type"]="bytes"; out["base64url"]=uce_base64url_encode((const unsigned char*)value.bytes.data(),value.bytes.size()); break; case UceCborKind::Text: out["type"]="text"; out["value"]=value.bytes; break; case UceCborKind::Array: out["type"]="array"; out["items"].set_array(); for(const auto& x:value.items) out["items"].push(uce_cbor_value(x)); break; case UceCborKind::Map: out["type"]="map"; out["entries"].set_array(); for(size_t i=0;ikind==UceCborKind::Unsigned&&kty->number==2&&alg->kind==UceCborKind::Negative&&alg->number==6&&crv->kind==UceCborKind::Unsigned&&crv->number==1&&xx->kind==UceCborKind::Bytes&&yy->kind==UceCborKind::Bytes&&xx->bytes.size()==32&&yy->bytes.size()==32&&(x=xx->bytes,true)&&(y=yy->bytes,true)); +} +static std::unique_ptr uce_es256_public_key(String x,String y) +{ + if(x.size()!=32||y.size()!=32) return nullptr; unsigned char point[65]={4}; memcpy(point+1,x.data(),32); memcpy(point+33,y.data(),32); OSSL_PARAM p[]={OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME,(char*)"prime256v1",0),OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY,point,sizeof(point)),OSSL_PARAM_construct_end()}; std::unique_ptr ctx(EVP_PKEY_CTX_new_from_name(0,"EC",0)); EVP_PKEY* raw=0; if(!ctx||EVP_PKEY_fromdata_init(ctx.get())<=0||EVP_PKEY_fromdata(ctx.get(),&raw,EVP_PKEY_PUBLIC_KEY,p)<=0) return nullptr; std::unique_ptr key(raw); std::unique_ptr check(EVP_PKEY_CTX_new(key.get(),0)); return check&&EVP_PKEY_public_check(check.get())>0?std::move(key):nullptr; +} +static bool uce_es256_cose(String encoded,String& x,String& y) +{ + String raw; UceCbor cose; size_t p=0,nodes=0; return uce_base64url_decode(encoded, raw, UCE_CBOR_MAX_BASE64URL) && raw.size() <= UCE_CBOR_MAX_BYTES && uce_cbor_read(raw, p, cose, 0, nodes) && p == raw.size() && uce_cbor_es256(cose, x, y) && uce_es256_public_key(x, y); +} + static DValue uce_es256_jwk(String x, String y, String d = "") { DValue jwk; @@ -811,6 +903,54 @@ DValue crypto_operation_native(DValue request) key["algorithm"] = algorithm; return(key); } + if(operation == "cbor_decode") + { + String encoded, raw; UceCbor value; size_t p=0,nodes=0; + if(!uce_es256_jwk_string(request, "cbor_base64url", encoded) || !uce_base64url_decode(encoded, raw, UCE_CBOR_MAX_BASE64URL) || raw.size() > UCE_CBOR_MAX_BYTES || !uce_cbor_read(raw, p, value, 0, nodes) || p != raw.size()) { result["error"] = "invalid_cbor"; return(result); } + result["ok"].set_bool(true); result["operation"]=operation; result["value"]=uce_cbor_value(value); return result; + } + if(operation == "cose_es256_parse") + { + String encoded,x,y; if(!uce_es256_jwk_string(request,"cose_key_base64url",encoded)||!uce_es256_cose(encoded,x,y)) { result["error"]="invalid_cose_key"; return result; } + result["ok"].set_bool(true); result["operation"]=operation; result["algorithm"]=algorithm; result["x_base64url"]=uce_base64url_encode((const unsigned char*)x.data(),x.size()); result["y_base64url"]=uce_base64url_encode((const unsigned char*)y.data(),y.size()); return result; + } + if(operation == "es256_verify") + { + String encoded, message64, signature64, x, y, message, der; + if(!uce_es256_jwk_string(request, "cose_key_base64url", encoded) || !uce_es256_jwk_string(request, "message_base64url", message64) || !uce_es256_jwk_string(request, "signature_der_base64url", signature64) || !uce_es256_cose(encoded, x, y) || !uce_base64url_decode(message64, message, UCE_CBOR_MAX_BASE64URL) || !uce_base64url_decode(signature64, der, UCE_ES256_DER_BASE64URL_MAX) || message.size() > UCE_CBOR_MAX_BYTES || der.empty() || der.size() > UCE_ES256_DER_MAX_BYTES) + { + result["error"] = "invalid_key_or_payload"; + return(result); + } + const unsigned char* cursor = (const unsigned char*)der.data(); + std::unique_ptr signature(d2i_ECDSA_SIG(0, &cursor, der.size())); + int canonical = signature ? i2d_ECDSA_SIG(signature.get(), 0) : 0; + String reencoded(canonical > 0 ? (size_t)canonical : 0, 0); + unsigned char* dest = (unsigned char*)reencoded.data(); + if(!signature || cursor != (const unsigned char*)der.data() + der.size() || canonical <= 0 || i2d_ECDSA_SIG(signature.get(), &dest) != canonical || reencoded != der) + { + result["error"] = "invalid_signature"; + return(result); + } + std::unique_ptr key = uce_es256_public_key(x, y); + std::unique_ptr ctx(EVP_MD_CTX_new(), EVP_MD_CTX_free); + if(!key || !ctx || EVP_DigestVerifyInit(ctx.get(), 0, EVP_sha256(), 0, key.get()) <= 0) + { + result["error"] = "operation_failed"; + return(result); + } + int verified = EVP_DigestVerify(ctx.get(), (const unsigned char*)der.data(), der.size(), (const unsigned char*)message.data(), message.size()); + if(verified < 0) + { + result["error"] = "operation_failed"; + return(result); + } + result["ok"].set_bool(true); + result["operation"] = operation; + result["algorithm"] = algorithm; + result["valid"].set_bool(verified == 1); + return(result); + } if(operation == "jwt_sign") { String jwt = uce_es256_jwt(request["private_jwk"], request["protected_header"], request["claims"]); diff --git a/src/lib/mysql-connector.cpp b/src/lib/mysql-connector.cpp index 7104093..122c2b1 100644 --- a/src/lib/mysql-connector.cpp +++ b/src/lib/mysql-connector.cpp @@ -246,9 +246,10 @@ DValue MySQL::query(String q) statement_info = "mysql connection is not open"; return(DValue()); } - _preload_next_error_code = mysql_query((MYSQL*)connection, q.c_str()); + int query_status = mysql_query((MYSQL*)connection, q.c_str()); + _preload_next_error_code = query_status == 0 ? 0 : mysql_errno((MYSQL*)connection); DValue result; - if(_preload_next_error_code == 0) + if(query_status == 0) result = get_pending_result(); return(result); } @@ -291,11 +292,16 @@ static bool mysql_has_unquoted_positional_placeholder(String query) DValue MySQL::query(String q, StringMap params) { - // Positional ? placeholders survive named substitution (values are always - // quoted by escape()), so the check in query(String) covers this path too. - return(query( - parse_query_parameters(q, params).c_str() - )); + // Positional ? placeholders survive named substitution (ordinary values + // are quoted by escape()), so the check in query(String) covers this path. + parameter_error = false; + String parsed = parse_query_parameters(q, params); + if(parameter_error) + { + _preload_next_error_code = CR_UNKNOWN_ERROR; + return(DValue()); + } + return(query(parsed)); } String MySQL::parse_query_parameters(String query, StringMap map) @@ -305,6 +311,7 @@ String MySQL::parse_query_parameters(String query, StringMap map) u8 mode = 0; char quote; + bool escaped = false; String identifier; for(u32 i = 0; i < query.length(); i++) { @@ -321,6 +328,7 @@ String MySQL::parse_query_parameters(String query, StringMap map) result.append(1, c); mode = 2; quote = c; + escaped = false; } else { @@ -329,10 +337,25 @@ String MySQL::parse_query_parameters(String query, StringMap map) } else if(mode == 1) // identifier mode { - if(isalnum(c) || c == '_') + if(isalnum((unsigned char)c) || c == '_') { identifier.append(1, c); } + else if(c == '!' && query[i + 1] != '=') + { + String value = map[identifier]; + bool valid = identifier != "" && value != ""; + for(char digit : value) + if(!isdigit((unsigned char)digit)) valid = false; + if(!valid) + { + parameter_error = true; + statement_info = "mysql unsigned parameter :" + identifier + "! must contain only decimal digits"; + return(""); + } + result.append(value); + mode = 0; + } else { result.append(escape(map[identifier])); @@ -342,9 +365,19 @@ String MySQL::parse_query_parameters(String query, StringMap map) } else if(mode == 2) // quoted mode { + result.append(1, c); + if(escaped) + { + escaped = false; + continue; + } + if(c == '\\') + { + escaped = true; + continue; + } if(c == quote) mode = 0; - result.append(1, c); } } @@ -390,10 +423,15 @@ String MySQL::error() case(CR_OUT_OF_MEMORY): p = "Out of memory"; break; - default: case(CR_UNKNOWN_ERROR): p = "Unknown server error"; break; + default: + if(connection && mysql_error((MYSQL*)connection)[0] != '\0') + p = mysql_error((MYSQL*)connection); + else + p = "Unknown server error"; + break; } _preload_next_error_code = 0; return(p); diff --git a/src/lib/mysql-connector.h b/src/lib/mysql-connector.h index ca332ef..31e4a23 100644 --- a/src/lib/mysql-connector.h +++ b/src/lib/mysql-connector.h @@ -20,6 +20,7 @@ struct MySQL { u32 row_count = 0; u64 insert_id = 0; String statement_info = ""; // + bool parameter_error = false; bool request_cleanup_delete = false; bool request_pooled = false; bool worker_persistent = false; diff --git a/src/lib/sys.cpp b/src/lib/sys.cpp index e7ed4c7..e2e1e09 100644 --- a/src/lib/sys.cpp +++ b/src/lib/sys.cpp @@ -325,6 +325,7 @@ u64 http_request_async(DValue req) return((u64)uce_host_http_request_async(encoded.data(), encoded.size())); } + DValue shell_exec(DValue spec) { String encoded = ucb_encode(spec); @@ -778,6 +779,7 @@ DValue crypto_operation(DValue request) { return(crypto_operation_native(request // Single definitions for the native split build (declared extern in sys.h). pid_t parent_pid = 0; pid_t my_pid = 0; +bool task_child_process = false; namespace { @@ -1996,6 +1998,7 @@ pid_t task(String key, std::function exec_after_spawn, u64 timeout) { close_locked_file(lock_fd); my_pid = getpid(); + task_child_process = true; // The FastCGI worker handles termination to drain accepted requests. // Generic task children do not run that drain loop, so inheriting those // handlers would turn task_kill(SIGTERM) into a no-op. diff --git a/src/lib/sys.h b/src/lib/sys.h index 31fc39b..7551a62 100644 --- a/src/lib/sys.h +++ b/src/lib/sys.h @@ -149,9 +149,11 @@ StringMap memcache_get_multiple(u64 connection, StringList keys); #if defined(__UCE_WASM_CORE__) || defined(__UCE_WASM_UNIT__) pid_t parent_pid = 0; pid_t my_pid = 0; +bool task_child_process = false; #else extern pid_t parent_pid; extern pid_t my_pid; +extern bool task_child_process; #endif void on_segfault(int sig); diff --git a/src/lib/uri.cpp b/src/lib/uri.cpp index ce342cf..74e4b29 100644 --- a/src/lib/uri.cpp +++ b/src/lib/uri.cpp @@ -97,7 +97,11 @@ String base64_decode(String raw, bool& ok) return(""); if(padding > 0 && i + 4 != cleaned.length()) return(""); - if(cleaned[i + 2] == '=' && cleaned[i + 3] != '=') + if(cleaned[i] == '=' || cleaned[i + 1] == '=') + return(""); + if(padding == 1 && (cleaned[i + 2] == '=' || cleaned[i + 3] != '=')) + return(""); + if(padding == 2 && (cleaned[i + 2] != '=' || cleaned[i + 3] != '=')) return(""); result.append(1, (char)((values[0] << 2) | (values[1] >> 4))); diff --git a/src/linux_fastcgi.cpp b/src/linux_fastcgi.cpp index c7e77b9..575bad3 100644 --- a/src/linux_fastcgi.cpp +++ b/src/linux_fastcgi.cpp @@ -1132,7 +1132,8 @@ void run_ws_broker() if(server_state.config["WS_BROKER_SOCKET_PATH"] != "") { ws_broker.listen(server_state.config["WS_BROKER_SOCKET_PATH"]); - chmod(server_state.config["WS_BROKER_SOCKET_PATH"].c_str(), S_IRWXU | S_IRGRP | S_IWGRP); + if(chmod(server_state.config["WS_BROKER_SOCKET_PATH"].c_str(), S_IRWXU | S_IRGRP | S_IWGRP) != 0) + fprintf(stderr, "(!) Could not chmod socket %s to %04o: %s\n", server_state.config["WS_BROKER_SOCKET_PATH"].c_str(), 0760, strerror(errno)); } while(!termination_signal_received) { diff --git a/src/wasm/core.cpp b/src/wasm/core.cpp index ffa5513..73e4b82 100644 --- a/src/wasm/core.cpp +++ b/src/wasm/core.cpp @@ -207,6 +207,7 @@ String MySQL::parse_query_parameters(String query, StringMap map) u8 mode = 0; char quote = 0; + bool escaped = false; String identifier; for(u32 i = 0; i < query.length(); i++) { @@ -223,14 +224,30 @@ String MySQL::parse_query_parameters(String query, StringMap map) result.append(1, c); mode = 2; quote = c; + escaped = false; } else result.append(1, c); } else if(mode == 1) { - if(isalnum(c) || c == '_') + if(isalnum((unsigned char)c) || c == '_') identifier.append(1, c); + else if(c == '!' && query[i + 1] != '=') + { + String value = map[identifier]; + bool valid = identifier != "" && value != ""; + for(char digit : value) + if(!isdigit((unsigned char)digit)) valid = false; + if(!valid) + { + parameter_error = true; + statement_info = "mysql unsigned parameter :" + identifier + "! must contain only decimal digits"; + return(""); + } + result.append(value); + mode = 0; + } else { result.append(escape(map[identifier])); @@ -240,9 +257,19 @@ String MySQL::parse_query_parameters(String query, StringMap map) } else if(mode == 2) { + result.append(1, c); + if(escaped) + { + escaped = false; + continue; + } + if(c == '\\') + { + escaped = true; + continue; + } if(c == quote) mode = 0; - result.append(1, c); } } @@ -315,7 +342,17 @@ DValue MySQL::query(String q) return(result ? *result : DValue()); } -DValue MySQL::query(String q, StringMap params) { return(query(parse_query_parameters(q, params))); } +DValue MySQL::query(String q, StringMap params) +{ + parameter_error = false; + String parsed = parse_query_parameters(q, params); + if(parameter_error) + { + _preload_next_error_code = 2000; + return(DValue()); + } + return(query(parsed)); +} DValue MySQL::get_pending_result() { return(DValue()); } // sqlite runs host-side (the host links libsqlite and owns the connections in diff --git a/src/wasm/hardened_http_internal.h b/src/wasm/hardened_http_internal.h new file mode 100644 index 0000000..a8c9d36 --- /dev/null +++ b/src/wasm/hardened_http_internal.h @@ -0,0 +1,146 @@ +// Internal worker-local implementation for opt-in generic hardened HTTP. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static u64 hardened_http_monotonic_ms() { timespec ts{}; clock_gettime(CLOCK_MONOTONIC,&ts); return (u64)ts.tv_sec*1000ull+(u64)ts.tv_nsec/1000000ull; } +static void hardened_http_close_inherited_fds() +{ +#ifdef SYS_close_range + if(syscall(SYS_close_range,4u,~0u,0u)==0) return; +#endif + long max_fd=sysconf(_SC_OPEN_MAX); if(max_fd<4) max_fd=4; + for(int fd=4;fd(String)> resolve; + std::function, String, std::vector, u64, size_t)> execute; +}; + +// Hardened requests deliberately support public IPv4 answers only. Every IPv6 +// answer fails closed until this generic transport has a reviewed IPv6 policy. +// IPv4 must be globally unicast: reject IANA special-purpose 0/8, 10/8, +// 100.64/10, 127/8, 169.254/16, 172.16/12, 192.0.0/24, 192.0.2/24, +// 192.31.196/24, 192.52.193/24, 192.88.99/24, 192.175.48/24, 192.168/16, +// 198.18/15, 198.51.100/24, 203.0.113/24, and all 224/4 multicast/reserved. +static bool hardened_http_public_address(String text) +{ + in_addr v4{}; + if(inet_pton(AF_INET,text.c_str(),&v4)!=1) return false; // Includes every AAAA candidate. + u32 x=ntohl(v4.s_addr); u8 a=x>>24,b=x>>16,c=x>>8; + if(a==0||a==10||a==127||a>=224||(a==100&&b>=64&&b<=127)||(a==169&&b==254)||(a==172&&b>=16&&b<=31)||(a==192&&(b==0||b==2||b==168))||(a==192&&b==31&&c==196)||(a==192&&b==52&&c==193)||(a==192&&b==88&&c==99)||(a==192&&b==175&&c==48)||(a==198&&(b==18||b==19||b==51))||(a==203&&b==0&&c==113)) return false; + return true; +} +// Three private pipes keep curl's -D headers and -o body distinct. The child +// inherits only stdin/stdout/stderr and the header pipe on fd 3. Async workers +// retain their own process group so job_cancel can kill curl and its descendants. +static HardenedHttpExecResult hardened_http_exec_argv_capture(std::vector argv, String input, u64 timeout_ms, size_t output_limit, bool clean_env=true, bool own_process_group=true) +{ + HardenedHttpExecResult r; if(argv.empty()) return r; + int in[2], body[2], headers[2], err[2]; + if(pipe(in)||pipe(body)||pipe(headers)||pipe(err)) return r; + pid_t pid=fork(); + if(pid==0) { + if(own_process_group) setpgid(0,0); + dup2(in[0],0); dup2(body[1],1); dup2(err[1],2); dup2(headers[1],3); + hardened_http_close_inherited_fds(); + std::vector args; for(String& a:argv) args.push_back((char*)a.c_str()); args.push_back(0); + if(clean_env) { clearenv(); setenv("PATH","/usr/bin:/bin",1); execv(args[0],args.data()); } else execvp(args[0],args.data()); + _exit(127); + } + auto close_all=[&](){ for(int fd: {in[0],in[1],body[0],body[1],headers[0],headers[1],err[0],err[1]}) close(fd); }; + if(pid<0) { close_all(); return r; } + if(own_process_group) setpgid(pid,pid); + close(in[0]); close(body[1]); close(headers[1]); close(err[1]); + for(int fd: {in[1],body[0],headers[0],err[0]}) fcntl(fd,F_SETFL,fcntl(fd,F_GETFL,0)|O_NONBLOCK); + size_t input_off=0, total=0; bool in_open=true,body_open=true,headers_open=true,err_open=true,exited=false,killed=false; int status=0; u64 deadline=hardened_http_monotonic_ms()+timeout_ms; + auto terminate=[&](bool timeout){ if(timeout) r.timed_out=true; if(!killed) { if(own_process_group) kill(-pid,SIGKILL); kill(pid,SIGKILL); killed=true; } }; + auto drain=[&](int fd,bool& open,String& dst) { char buf[4096]; ssize_t n; while((n=read(fd,buf,sizeof(buf)))>0) { if(total+(size_t)n>output_limit) { r.output_limited=true; terminate(false); } else { dst+=String(buf,n); total+=(size_t)n; } } if(n==0) { close(fd); open=false; } }; + while(in_open||body_open||headers_open||err_open||!exited) { + if(!exited) { pid_t w=waitpid(pid,&status,WNOHANG); if(w==pid) exited=true; } + if(in_open) { if(input_off0) input_off+=(size_t)n; else if(n<0&&errno!=EINTR&&errno!=EAGAIN&&errno!=EWOULDBLOCK) { close(in[1]); in_open=false; } } else { close(in[1]); in_open=false; } } + drain(body[0],body_open,r.body_text); drain(headers[0],headers_open,r.headers_text); drain(err[0],err_open,r.stderr_text); + if(!killed&&hardened_http_monotonic_ms()>=deadline) terminate(true); + if(killed&&!exited) { while(waitpid(pid,&status,0)<0&&errno==EINTR) {} exited=true; } + if(in_open||body_open||headers_open||err_open||!exited) usleep(1000); + } + if(WIFEXITED(status)) r.exit_code=WEXITSTATUS(status); else if(WIFSIGNALED(status)) r.exit_code=128+WTERMSIG(status); + return r; +} + +static bool hardened_http_parse_headers(String text, u64& status, DValue& filtered) +{ + status=0; filtered.set_array(); size_t pos=0; bool found=false; + while(pos8192||text.find('\0')!=String::npos||text.find('\r')!=String::npos||text.find('\n')!=String::npos) return false; + return true; +} +static bool hardened_http_ip_literal(String text) +{ + in_addr v4{}; in6_addr v6{}; + return inet_pton(AF_INET,text.c_str(),&v4)==1 || inet_pton(AF_INET6,text.c_str(),&v6)==1; +} +static bool hardened_http_url(String url, String& host, String& port) +{ + if(url.find('\0')!=String::npos||url.find_first_of("\\ \r\n\t")!=String::npos||url.rfind("https://",0)!=0) return false; + String rest=url.substr(8); size_t slash=rest.find('/'); String authority=slash==String::npos?rest:rest.substr(0,slash); + if(authority==""||authority.find('@')!=String::npos||authority.find('[')!=String::npos||authority.find(']')!=String::npos) return false; + size_t colon=authority.rfind(':'); host=colon==String::npos?authority:authority.substr(0,colon); port=colon==String::npos?"443":authority.substr(colon+1); + if(host==""||hardened_http_ip_literal(host)||!hardened_http_token(port)) return false; + for(unsigned char c:host) if(!(isalnum(c)||c=='.'||c=='-')) return false; + char* end=0; unsigned long n=strtoul(port.c_str(),&end,10); return end!=port.c_str()&&*end==0&&n>0&&n<=65535; +} +static bool hardened_http_security_requested(const DValue* security) +{ + return security && (security->key("https_only") || security->key("public_dns_only") || security->key("pin_dns") || security->key("isolated_curl") || security->key("no_redirects")); +} +static bool hardened_http_true(const DValue* value) +{ + return value && value->get_type_name()=="bool" && value->to_bool(); +} +static DValue hardened_http_request_internal(const DValue& req, u64 timeout_ms, const HardenedHttpHooks& hooks) +{ + DValue r; r["status"]=(f64)0; r["headers"].set_array(); r["body"]=""; r["error"]=""; + const DValue* sec=req.key("security"); + if(req.key("follow_redirects") && req.key("follow_redirects")->to_bool()) { r["error"]="invalid_request"; return r; } + timeout_ms=std::min(std::max(1,timeout_ms),10000); + if(!sec || !hardened_http_true(sec->key("https_only")) || !hardened_http_true(sec->key("public_dns_only")) || !hardened_http_true(sec->key("pin_dns")) || !hardened_http_true(sec->key("isolated_curl")) || !hardened_http_true(sec->key("no_redirects"))) { r["error"]="invalid_request"; return r; } + String method=req.key("method")?req.key("method")->to_string():"GET"; String url=req.key("url")?req.key("url")->to_string():""; String host,port; + if((method!="GET"&&method!="POST"&&method!="PUT"&&method!="PATCH"&&method!="DELETE"&&method!="HEAD"&&method!="OPTIONS")||!hardened_http_url(url,host,port)) { r["error"]="invalid_request"; return r; } + String body=req.key("body")?req.key("body")->to_string():""; if(body.size()>65536||body.find('\0')!=String::npos) { r["error"]="invalid_request"; return r; } + std::vector argv={"/usr/bin/curl","--disable","-sS","--http1.1","--proto","=https","--proto-redir","=https","--noproxy","*","--proxy","","--alt-svc","","--hsts","","--cacert","/etc/ssl/certs/ca-certificates.crt","--connect-timeout","3","--max-time",std::to_string(std::max(1,timeout_ms/1000)),"--max-filesize","65536","-X",method,"-D","/proc/self/fd/3","-o","/proc/self/fd/1"}; + const DValue* hs=req.key("headers"); if(hs) { bool valid=true; hs->each([&](const DValue& v,String k) { String value=v.to_string(), lower=to_lower(k); if(!hardened_http_token(k)||!hardened_http_header_value(value)||lower=="host"||lower=="content-length"||lower=="transfer-encoding"||lower=="connection"||lower=="proxy-connection"||lower=="expect") valid=false; else { argv.push_back("-H"); argv.push_back(k+": "+value); } }); if(!valid) { r["error"]="invalid_request"; return r; } } + std::vector answers=hooks.resolve(host); String address; for(String a:answers) { if(!hardened_http_public_address(a)) { r["error"]="unsafe_dns"; return r; } if(address=="") address=a; } if(address=="") { r["error"]="unsafe_dns"; return r; } + argv.push_back("--resolve"); argv.push_back(host+":"+port+":"+address); if(req.key("body")) { argv.push_back("--data-binary"); argv.push_back("@-"); } argv.push_back(url); + HardenedHttpExecResult pr=hooks.execute(argv,body,{"PATH=/usr/bin:/bin"},timeout_ms,72*1024); if(pr.output_limited||pr.body_text.size()>65536||pr.headers_text.size()>8192) { r["error"]="response_too_large"; return r; } + u64 status; DValue headers; if(!hardened_http_parse_headers(pr.headers_text,status,headers)) { r["error"]=pr.timed_out?"timeout":"malformed_output"; return r; } + r["status"]=(f64)status; r["headers"]=headers; r["body"]=pr.body_text; + if(status/100==3) r["error"]="redirect_not_allowed"; else if(pr.exit_code!=0) r["error"]=pr.timed_out?"timeout":"network_failure"; else if(status/100!=2) r["error"]="http_status"; return r; +} diff --git a/src/wasm/worker.cpp b/src/wasm/worker.cpp index 11f58d2..7fb3754 100644 --- a/src/wasm/worker.cpp +++ b/src/wasm/worker.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +56,7 @@ #include #include #include +#include "hardened_http_internal.h" struct WasmDylinkInfo { @@ -390,8 +392,6 @@ static u64 wasm_socket_connect_bounded(const String& host, u16 port, u64 timeout } if(fd <= 0) return(0); - if(context) - context->resources.sockets.push_back(fd); return((u64)fd); } @@ -622,7 +622,7 @@ static u64 uce_shell_spawn_spec(const DValue& spec) } -static DValue uce_exec_argv_capture(std::vector argv, String input, u64 timeout_ms) +static DValue uce_exec_argv_capture(std::vector argv, String input, u64 timeout_ms, size_t output_limit=0, bool clean_env=false) { DValue r; r["exit_code"]=(f64)-1; r["stdout"]=""; r["stderr"]=""; r["timed_out"].set_bool(false); if(argv.empty()) { r["stderr"]="empty argv"; return(r); } @@ -639,7 +639,9 @@ static DValue uce_exec_argv_capture(std::vector argv, String input, u64 dup2(inpipe[0],0); dup2(outpipe[1],1); dup2(errpipe[1],2); close(inpipe[0]); close(inpipe[1]); close(outpipe[0]); close(outpipe[1]); close(errpipe[0]); close(errpipe[1]); std::vector args; for(auto& a: argv) args.push_back((char*)a.c_str()); args.push_back(0); - execvp(args[0], args.data()); _exit(127); + if(clean_env) { clearenv(); setenv("PATH", "/usr/bin:/bin", 1); execv(args[0], args.data()); } + else execvp(args[0], args.data()); + _exit(127); } if(pid < 0) { @@ -655,8 +657,8 @@ static DValue uce_exec_argv_capture(std::vector argv, String input, u64 { if(!exited) { pid_t w=waitpid(pid,&status,WNOHANG); if(w==pid) { exited=true; status_valid=true; } else if(w<0&&errno==ECHILD) { u64 transfer_deadline=wasm_deadline_after_ms(50); do { status_valid=child_exit_status_take(pid,status,child_status_snapshot); if(!status_valid)sched_yield(); } while(!status_valid&&wasm_monotonic_ms()0) input_off+=(size_t)n; else if(n<0 && errno!=EINTR && errno!=EAGAIN && errno!=EWOULDBLOCK) { close(inpipe[1]); in_open=false; } } else { close(inpipe[1]); in_open=false; } } - char buf[4096]; ssize_t n; while((n=read(outpipe[0],buf,sizeof(buf)))>0) r["stdout"] = r["stdout"].to_string()+String(buf,n); if(n==0&&out_open){close(outpipe[0]);out_open=false;} - while((n=read(errpipe[0],buf,sizeof(buf)))>0) r["stderr"] = r["stderr"].to_string()+String(buf,n); if(n==0&&err_open){close(errpipe[0]);err_open=false;} + char buf[4096]; ssize_t n; while((n=read(outpipe[0],buf,sizeof(buf)))>0) { if(output_limit && r["stdout"].to_string().size()+(size_t)n>output_limit) { r["output_limited"].set_bool(true); kill(-pid,SIGKILL); kill(pid,SIGKILL); } else r["stdout"] = r["stdout"].to_string()+String(buf,n); } if(n==0&&out_open){close(outpipe[0]);out_open=false;} + while((n=read(errpipe[0],buf,sizeof(buf)))>0) { if(output_limit && r["stderr"].to_string().size()+(size_t)n>output_limit) { r["output_limited"].set_bool(true); kill(-pid,SIGKILL); kill(pid,SIGKILL); } else r["stderr"] = r["stderr"].to_string()+String(buf,n); } if(n==0&&err_open){close(errpipe[0]);err_open=false;} if((out_open || err_open || !exited) && wasm_monotonic_ms() >= deadline) { r["timed_out"].set_bool(true); kill(-pid,SIGKILL); kill(pid,SIGKILL); if(!exited) status_valid=waitpid(pid,&status,0)==pid; exited=true; if(in_open){close(inpipe[1]);in_open=false;} if(out_open){close(outpipe[0]);out_open=false;} if(err_open){close(errpipe[0]);err_open=false;} } if(out_open || err_open || !exited) usleep(10000); } @@ -671,8 +673,11 @@ static bool uce_header_name_safe(String name) return(true); } +static DValue uce_hardened_http_request_value(const DValue& req, u64 timeout_ms, bool keep_worker_process_group=false); + static DValue uce_http_request_value(const DValue& req) { + const DValue* security=req.key("security"); if(hardened_http_security_requested(security)) { u64 requested=req.key("timeout_ms")?req.key("timeout_ms")->to_u64(5000):5000; return(uce_hardened_http_request_value(req,std::max(1,requested))); } DValue r; r["status"]=(f64)0; r["headers"].set_array(); r["body"]=""; r["error"]=""; const DValue* method_value = req.key("method"); const DValue* url_value = req.key("url"); @@ -706,19 +711,35 @@ static DValue uce_http_request_value(const DValue& req) return(r); } +static DValue uce_hardened_http_request_value(const DValue& req, u64 timeout_ms, bool keep_worker_process_group) +{ + HardenedHttpHooks hooks; + hooks.resolve=[](String host) { std::vector answers; addrinfo hints{}; hints.ai_socktype=SOCK_STREAM; hints.ai_family=AF_UNSPEC; addrinfo* result=0; if(getaddrinfo(host.c_str(), "443", &hints, &result)!=0) return answers; for(addrinfo* p=result;p;p=p->ai_next) { char text[INET6_ADDRSTRLEN]; if(p->ai_family==AF_INET && inet_ntop(AF_INET,&((sockaddr_in*)p->ai_addr)->sin_addr,text,sizeof(text))) answers.push_back(text); else if(p->ai_family==AF_INET6 && inet_ntop(AF_INET6,&((sockaddr_in6*)p->ai_addr)->sin6_addr,text,sizeof(text))) answers.push_back(text); else answers.push_back(""); } freeaddrinfo(result); return answers; }; + hooks.execute=[keep_worker_process_group](std::vector argv, String input, std::vector, u64 deadline, size_t limit) { return hardened_http_exec_argv_capture(argv,input,deadline,limit,true,!keep_worker_process_group); }; + return hardened_http_request_internal(req,timeout_ms,hooks); +} + static u64 uce_http_spawn_spec(const DValue& req) { uce_job_reap(); u64 id=uce_job_new("http"); if(!id) return(0); + int ready[2]; if(pipe(ready)) { DValue r; r["error"]="pipe failed"; uce_job_finish(id,r,"failed"); return(id); } pid_t pid=fork(); - if(pid==0) { setsid(); uce_write_text(uce_job_path(id)+"/worker_pid", std::to_string((long long)getpid())); uce_write_text(uce_job_path(id)+"/state", "running"); DValue result=uce_http_request_value(req); uce_job_finish(id,result,result["error"].to_string()==""?"done":"failed"); _exit(0); } - if(pid<0) { DValue r; r["error"]="fork failed"; uce_job_finish(id,r,"failed"); return(id); } + if(pid==0) { close(ready[0]); if(setsid()<0) _exit(127); char ok='1'; if(write(ready[1],&ok,1)!=1) _exit(127); close(ready[1]); uce_write_text(uce_job_path(id)+"/worker_pid", std::to_string((long long)getpid())); uce_write_text(uce_job_path(id)+"/state", "running"); const DValue* security=req.key("security"); bool hardened=hardened_http_security_requested(security); DValue result=hardened ? uce_hardened_http_request_value(req,req.key("timeout_ms")?std::max(1,req.key("timeout_ms")->to_u64(5000)):5000,true) : uce_http_request_value(req); uce_job_finish(id,result,result["error"].to_string()==""?"done":"failed"); _exit(0); } + close(ready[1]); char ok=0; ssize_t started; do { started=read(ready[0],&ok,1); } while(started<0&&errno==EINTR); close(ready[0]); + if(pid<0 || started!=1 || ok!='1') { DValue r; r["error"]="async worker start failed"; uce_job_finish(id,r,"failed"); return(id); } uce_write_text(uce_job_path(id)+"/worker_pid", std::to_string((long long)pid)); uce_write_text(uce_job_path(id)+"/state", "running"); return(id); } static DValue uce_job_status_value(u64 id) { DValue r; String dir=uce_job_path(id); r["job_id"]=(f64)id; - if(id==0 || !std::filesystem::is_directory(dir)) { r["state"]="missing"; return(r); } + bool is_directory = false; + if(id != 0) + { + try { is_directory = std::filesystem::is_directory(dir); } + catch(...) { is_directory = false; } + } + if(!is_directory) { r["state"]="missing"; return(r); } String state=trim(uce_read_text(dir+"/state")); if(state=="") state="pending"; r["state"]=state; r["kind"]=trim(uce_read_text(dir+"/kind")); r["pid"]=(f64)strtoull(uce_read_text(dir+"/worker_pid").c_str(),0,10); r["done"].set_bool(state=="done"||state=="failed"||state=="cancelled"); @@ -1868,6 +1889,7 @@ public: bool writable = false; }; std::vector file_handles; + std::vector socket_fds; struct RequestPerfSnapshot { @@ -2025,7 +2047,9 @@ public: // the wasm-side enforcement of request-scoped DB lifecycle; app code should // never cache these opaque handles across requests. std::vector sqlite_handles; - std::vector mysql_handles; + std::map mysql_handles; + std::set mysql_task_handles; + u64 mysql_next_handle = 1; std::vector mysql_request_pool; std::vector mysql_request_owned; #endif @@ -2042,6 +2066,14 @@ public: h.fd = -1; } } + for(auto& fd : socket_fds) + { + if(fd >= 0) + { + ::socket_close((u64)fd); + fd = -1; + } + } #ifdef UCE_WASM_HOST_CONNECTORS for(auto* db : sqlite_handles) if(db) @@ -3682,15 +3714,20 @@ private: int32_t input_size = args[1].i32(); u32 cap = (u32)args[3].i32(); int32_t buf = args[2].i32(); - String stage_key = "crypto_operation"; - if(!self->hostcall_staged(stage_key, out)) + if(input_size > 0 && input_size <= 64 * 1024 && self->hostcall_read(args[0].i32(), input_size, encoded) == "" && ucb_decode(encoded, request, &error)) { - if(input_size > 0 && input_size <= 64 * 1024 && self->hostcall_read(args[0].i32(), input_size, encoded) == "" && ucb_decode(encoded, request, &error)) + String stage_key = "crypto_operation:" + encoded; + if(!self->hostcall_staged(stage_key, out)) + { response = crypto_operation_native(request); - else - response["error"] = "invalid_request"; + out = ucb_encode(response); + if(buf == 0) self->hostcall_stage(stage_key, out); + } + } + else + { + response["error"] = "invalid_request"; out = ucb_encode(response); - if(buf == 0) self->hostcall_stage(stage_key, out); } if(buf && cap >= out.size()) self->hostcall_write(buf, out); results[0] = Val((int32_t)out.size()); @@ -4122,7 +4159,7 @@ private: if(mod == "env" && name == "uce_host_file_truncate") return(add([self](Caller, Span args, Span results) -> Result { String path,current; self->hostcall_read(args[0].i32(),args[1].i32(),path); self->hostcall_read(args[2].i32(),args[3].i32(),current); String r=self->resolve_guest_write(path,current); results[0]=Val((int32_t)(r!=""&&truncate(r.c_str(),(off_t)args[4].i64())==0)); return(std::monostate()); })); if(mod == "env" && name == "uce_host_dir_remove") - return(add([self](Caller, Span args, Span results) -> Result { String path,current; self->hostcall_read(args[0].i32(),args[1].i32(),path); self->hostcall_read(args[2].i32(),args[3].i32(),current); String r=self->resolve_guest_write(path,current); bool rec=args[4].i32()!=0; bool ok=false; if(r!="") { if(rec) ok=std::filesystem::remove_all(r)>0; else ok=::rmdir(r.c_str())==0; } results[0]=Val((int32_t)ok); return(std::monostate()); })); + return(add([self](Caller, Span args, Span results) -> Result { String path,current; self->hostcall_read(args[0].i32(),args[1].i32(),path); self->hostcall_read(args[2].i32(),args[3].i32(),current); String r=self->resolve_guest_write(path,current); bool rec=args[4].i32()!=0; bool ok=false; if(r!="") { if(rec) { try { ok=std::filesystem::remove_all(r)>0; } catch(...) { ok=false; } } else ok=::rmdir(r.c_str())==0; } results[0]=Val((int32_t)ok); return(std::monostate()); })); if(mod == "env" && name == "uce_host_file_temp") return(add([self](Caller, Span args, Span results) -> Result { String prefix,current; self->hostcall_read(args[0].i32(),args[1].i32(),prefix); self->hostcall_read(args[2].i32(),args[3].i32(),current); u32 cap=(u32)args[5].i32(); int32_t buf=args[4].i32(); String out; String stage_key="file_temp:"+prefix+"\0"+current; if(!self->hostcall_staged(stage_key,out)) { if(prefix=="") prefix="/tmp/uce-temp"; String templ=self->resolve_guest_write(prefix+"XXXXXX",current); if(templ!="") { std::vector t(templ.begin(), templ.end()); t.push_back(0); int fd=mkstemp(t.data()); if(fd>=0) { close(fd); out=t.data(); } } if(buf==0) self->hostcall_stage(stage_key,out); } if(buf&&cap>=out.size()) self->hostcall_write(buf,out); results[0]=Val((int32_t)out.size()); return(std::monostate()); })); if(mod == "env" && name == "uce_host_file_chmod") @@ -4332,9 +4369,15 @@ private: })); if(mod == "env" && name == "uce_host_memcache_command") return(add([self](Caller caller, Span args, Span results) -> Result { + u64 handle = (u64)args[0].i64(); + if(handle < 1 || handle > self->socket_fds.size() || self->socket_fds[(size_t)handle - 1] < 0) + { + results[0] = Val((int32_t)0); + return(std::monostate()); + } String command; self->hostcall_read(args[1].i32(), args[2].i32(), command); - String key = "memcache:" + std::to_string((u64)args[0].i64()) + ":" + command; + String key = "memcache:" + std::to_string(handle) + ":" + command; u32 cap = (u32)args[4].i32(); int32_t buf = args[3].i32(); String out; @@ -4346,7 +4389,7 @@ private: } else { - u64 socket_fd = (u64)args[0].i64(); + u64 socket_fd = (u64)self->socket_fds[(size_t)handle - 1]; out = wasm_memcache_exchange(socket_fd, command, self->bounded_hostcall_timeout_ms(1000)); if(buf == 0) { @@ -4384,41 +4427,60 @@ private: String password = request["password"].to_string(); String database = request["database"].to_string(); MySQL* db = 0; - for(auto* pooled : self->mysql_request_pool) - if(pooled && pooled->connection && pooled->request_host == host && pooled->request_username == username && pooled->request_password == password && pooled->request_database == database) - { - db = pooled; - connection_source = "request"; - break; - } - bool ok = db != 0; - if(!db) + bool ok = false; + if(task_child_process) { - bool reused = false; - bool persistent = false; - db = self->worker.mysql_checkout(host, username, password, database, reused, persistent); - connection_source = reused ? "worker" : "new"; - ok = db && db->connection; - if(ok && db->connection) + // task() closes inherited descriptors after fork. Never reuse + // request/worker MySQL objects whose sockets were just closed. + db = new MySQL(); + ok = db->connect(host, username, password, database); + connection_source = "task"; + if(ok) { self->mysql_request_pool.push_back(db); - if(!persistent) - self->mysql_request_owned.push_back(db); + self->mysql_request_owned.push_back(db); + } + } + else + { + for(auto* pooled : self->mysql_request_pool) + if(pooled && pooled->connection && pooled->request_host == host && pooled->request_username == username && pooled->request_password == password && pooled->request_database == database) + { + db = pooled; + connection_source = "request"; + break; + } + ok = db != 0; + if(!db) + { + bool reused = false; + bool persistent = false; + db = self->worker.mysql_checkout(host, username, password, database, reused, persistent); + connection_source = reused ? "worker" : "new"; + ok = db && db->connection; + if(ok) + { + self->mysql_request_pool.push_back(db); + if(!persistent) + self->mysql_request_owned.push_back(db); + } } } u64 handle = 0; - if(ok && db->connection) + if(ok && db->connection && self->mysql_next_handle != 0) { db->request_leases++; - self->mysql_handles.push_back(db); - handle = self->mysql_handles.size(); + handle = self->mysql_next_handle; + self->mysql_next_handle = handle == UINT64_MAX ? 0 : handle + 1; + self->mysql_handles[handle] = db; + if(task_child_process) self->mysql_task_handles.insert(handle); if(connection_source == "new") self->mysql_connection_open_count++; else if(connection_source == "worker") self->mysql_connection_reuse_count++; else if(connection_source == "request") self->mysql_request_pool_hit_count++; } response["handle"] = (f64)handle; - response["error_code"] = (f64)db->_preload_next_error_code; - response["statement_info"] = db->error(); + response["error_code"] = (f64)(handle == 0 ? 2000 : db->_preload_next_error_code); + response["statement_info"] = handle == 0 ? String("mysql handle space exhausted") : db->error(); if(handle == 0 && db) delete db; } @@ -4430,8 +4492,8 @@ private: else { u64 handle = request["handle"].to_u64(); - MySQL* db = (handle >= 1 && handle <= self->mysql_handles.size()) - ? self->mysql_handles[(size_t)handle - 1] : 0; + auto handle_it = self->mysql_handles.find(handle); + MySQL* db = handle_it == self->mysql_handles.end() || (task_child_process && self->mysql_task_handles.find(handle) == self->mysql_task_handles.end()) ? 0 : handle_it->second; if(op == "query" && db) { response["result"] = db->query(request["query"].to_string()); @@ -4444,7 +4506,20 @@ private: { if(db->request_leases > 0) db->request_leases--; - self->mysql_handles[(size_t)handle - 1] = 0; + self->mysql_handles.erase(handle); + self->mysql_task_handles.erase(handle); + if(task_child_process && db->request_leases == 0) + { + auto erase_db = [db](auto& pool) { pool.erase(std::remove(pool.begin(), pool.end(), db), pool.end()); }; + erase_db(self->mysql_request_pool); + erase_db(self->mysql_request_owned); + delete db; + } + } + else if(op == "query" || op == "disconnect") + { + response["error_code"] = (f64)2000; + response["statement_info"] = "mysql handle is invalid or unavailable in this task"; } } } @@ -4486,24 +4561,52 @@ private: String host; self->hostcall_read(args[0].i32(), args[1].i32(), host); u64 fd = wasm_socket_connect_bounded(host, (u16)args[2].i32(), self->bounded_hostcall_timeout_ms(self->worker.cfg.invocation_timeout_ms)); - results[0] = Val((int64_t)fd); + u64 handle = 0; + if(fd > 0) + { + self->socket_fds.push_back((int)fd); + handle = self->socket_fds.size(); + } + results[0] = Val((int64_t)handle); return(std::monostate()); })); if(mod == "env" && name == "uce_host_socket_close") - return(add([](Caller, Span args, Span) -> Result { - ::socket_close((u64)args[0].i64()); + return(add([self](Caller, Span args, Span) -> Result { + u64 handle = (u64)args[0].i64(); + if(handle >= 1 && handle <= self->socket_fds.size()) + { + int& fd = self->socket_fds[(size_t)handle - 1]; + if(fd >= 0) + { + ::socket_close((u64)fd); + fd = -1; + } + } return(std::monostate()); })); if(mod == "env" && name == "uce_host_socket_write") return(add([self](Caller, Span args, Span results) -> Result { + u64 handle = (u64)args[0].i64(); String data; self->hostcall_read(args[1].i32(), args[2].i32(), data); - results[0] = Val(wasm_socket_write_bounded((u64)args[0].i64(), data, self->bounded_hostcall_timeout_ms(self->worker.cfg.invocation_timeout_ms)) ? (int32_t)1 : (int32_t)0); + bool ok = false; + if(handle >= 1 && handle <= self->socket_fds.size()) + { + int fd = self->socket_fds[(size_t)handle - 1]; + ok = fd >= 0 && wasm_socket_write_bounded((u64)fd, data, self->bounded_hostcall_timeout_ms(self->worker.cfg.invocation_timeout_ms)); + } + results[0] = Val(ok ? (int32_t)1 : (int32_t)0); return(std::monostate()); })); if(mod == "env" && name == "uce_host_socket_read") return(add([self](Caller caller, Span args, Span results) -> Result { - u64 sockfd = (u64)args[0].i64(); + u64 handle = (u64)args[0].i64(); + if(handle < 1 || handle > self->socket_fds.size() || self->socket_fds[(size_t)handle - 1] < 0) + { + results[0] = Val((int32_t)0); + return(std::monostate()); + } + u64 sockfd = (u64)self->socket_fds[(size_t)handle - 1]; u32 max_length = (u32)args[1].i32(); u32 requested_timeout = (u32)args[2].i32(); u64 requested_ms = requested_timeout == 0 ? self->invocation_remaining_ms() : (u64)requested_timeout * 1000; @@ -4511,7 +4614,7 @@ private: int32_t buf = args[3].i32(); u32 cap = (u32)args[4].i32(); // The size and fetch calls share this key, while the remaining budget may change between them. - String key = std::to_string(sockfd) + ":" + std::to_string(max_length) + ":" + std::to_string(requested_timeout); + String key = std::to_string(handle) + ":" + std::to_string(max_length) + ":" + std::to_string(requested_timeout); String out; if(buf != 0 && self->staged_socket_read_key == key) { @@ -4578,7 +4681,7 @@ private: // before the hostcall stack unwinds, so `self` points to the child's // copy of this per-request workspace. The parent request can return and // destroy its workspace without invalidating the child copy. - u64 task_timeout_ms = timeout > UINT64_MAX / 1000 ? UINT64_MAX : timeout * 1000; + u64 task_timeout_ms = timeout == 0 ? UINT64_MAX : (timeout > UINT64_MAX / 1000 ? UINT64_MAX : timeout * 1000); auto run_callback = [self, callback_id, task_timeout_ms]() { String error = self->run_task_callback(callback_id, task_timeout_ms); if(error != "")