diff --git a/scripts/api_coverage_manifest.py b/scripts/api_coverage_manifest.py index 70f5edb..46044a3 100755 --- a/scripts/api_coverage_manifest.py +++ b/scripts/api_coverage_manifest.py @@ -40,7 +40,9 @@ PUBLIC_APIS = [ ("crypto_equal", True, "public"), ("gen_noise32", True, "public"), ("gen_noise64", True, "public"), ("gen_noise01", True, "public"), ("gen_int", True, "public"), ("gen_float", True, "public"), ("draw_int", True, "public"), ("draw_float", True, "public"), - ("encode_query", True, "public"), ("request_script_url", True, "public"), + ("encode_query", True, "public"), ("csrf_token", True, "public"), + ("csrf_valid", True, "public"), ("csrf_rotate", True, "public"), + ("request_script_url", True, "public"), ("request_base_url", True, "public"), ("request_route_from_raw_path", True, "public"), ("cli_arg", True, "public"), ("unit_compile", True, "public"), ("cleanup_sqlite_connections", False, "internal"), ("cleanup_mysql_connections", False, "internal"), diff --git a/site/doc/pages/csrf_rotate.txt b/site/doc/pages/csrf_rotate.txt new file mode 100644 index 0000000..a3e1678 --- /dev/null +++ b/site/doc/pages/csrf_rotate.txt @@ -0,0 +1,21 @@ +:sig +void csrf_rotate(String session_name = "uce-session", String token_name = "csrf_token") + +:params +session_name : session/cookie name used by `csrf_token()` +token_name : form/action token namespace to clear + +:content +Starts the named session if needed and clears the stored CSRF token for `token_name`. The next `csrf_token()` call creates a new token. Rotate after sensitive successful mutations when replaying the same submitted form should fail. + +:example +String old_token = csrf_token(); +csrf_rotate(); +String new_token = csrf_token(); +print(old_token != new_token ? "rotated\n" : "unchanged\n"); + +:see +>http +csrf_token +csrf_valid +session_start diff --git a/site/doc/pages/csrf_token.txt b/site/doc/pages/csrf_token.txt new file mode 100644 index 0000000..f47a1da --- /dev/null +++ b/site/doc/pages/csrf_token.txt @@ -0,0 +1,26 @@ +:sig +String csrf_token(String session_name = "uce-session", String token_name = "csrf_token") + +:params +session_name : session/cookie name to store the token under +token_name : form/action token namespace inside the session +return value : printable CSRF token for the active session + +:content +Starts the named session if needed and returns a stable per-session CSRF token for `token_name`. Use it in hidden fields on forms that mutate server state, then verify the submitted value with `csrf_valid()` before applying the change. + +Tokens are generated from cryptographic randomness and stored in `context.session` under an internal key. Use a different `token_name` when independent forms should have independent tokens. + +:example +String token = csrf_token("uce-doc-session"); +?>
+ + +
http +csrf_valid +csrf_rotate +session_start +random_bytes diff --git a/site/doc/pages/csrf_valid.txt b/site/doc/pages/csrf_valid.txt new file mode 100644 index 0000000..b79ba37 --- /dev/null +++ b/site/doc/pages/csrf_valid.txt @@ -0,0 +1,25 @@ +:sig +bool csrf_valid(String submitted_token, String session_name = "uce-session", String token_name = "csrf_token") + +:params +submitted_token : value received from the request, usually `context.post["csrf_token"]` +session_name : session/cookie name used by `csrf_token()` +token_name : form/action token namespace used by `csrf_token()` +return value : `true` when the submitted token matches the session token + +:content +Starts the named session if needed and checks `submitted_token` against the stored CSRF token using constant-time comparison. It does not create a token when none exists, so a request without a previously rendered form token fails closed. + +Use this before mutating state from POST forms or other browser-submitted requests. + +:example +String token = csrf_token("uce-doc-session"); +print(csrf_valid(token, "uce-doc-session") ? "valid\n" : "invalid\n"); +print(csrf_valid("bad-token", "uce-doc-session") ? "bad valid\n" : "bad invalid\n"); + +:see +>http +csrf_token +csrf_rotate +crypto_equal +session_start diff --git a/site/tests/http.uce b/site/tests/http.uce index 3a696ae..7ac3df4 100644 --- a/site/tests/http.uce +++ b/site/tests/http.uce @@ -34,6 +34,22 @@ RENDER(Request& context) String malformed_percent = uri_decode("% %A %GG ok%20done"); URI empty_uri = parse_uri(""); String generated_session = session_id_create(); + String csrf = ""; + String csrf_rotated = ""; + bool csrf_ok = false; + bool csrf_bad = false; + bool csrf_old_after_rotate = false; + bool csrf_new_after_rotate = false; + if(action != "destroy") + { + csrf = csrf_token("uce-site-tests", "http_form"); + csrf_ok = csrf_valid(csrf, "uce-site-tests", "http_form"); + csrf_bad = csrf_valid("not-" + csrf, "uce-site-tests", "http_form"); + csrf_rotate("uce-site-tests", "http_form"); + csrf_old_after_rotate = csrf_valid(csrf, "uce-site-tests", "http_form"); + csrf_rotated = csrf_token("uce-site-tests", "http_form"); + csrf_new_after_rotate = csrf_rotated != "" && csrf_rotated != csrf && csrf_valid(csrf_rotated, "uce-site-tests", "http_form"); + } String query_dump = var_dump(query); String set_cookie_dump = var_dump(context.set_cookies); String header_dump = var_dump(context.header); @@ -55,6 +71,8 @@ RENDER(Request& context) check("response header mutation", context.header["X-Site-Tests"] == "http-suite", header_dump); check("session_start()", session_id != "", "session_id=" + session_id); check("session_id_create()", generated_session.length() >= 16, generated_session); + if(action != "destroy") + check("csrf_token() / csrf_valid() / csrf_rotate()", csrf.length() >= 32 && csrf_ok && !csrf_bad && !csrf_old_after_rotate && csrf_new_after_rotate, "csrf=" + csrf + " rotated=" + csrf_rotated); if(action == "destroy") check("session_destroy()", session_dump.find("suite") == String::npos && context.session_id == "" && context.session_name == "" && !file_exists(session_path), session_dump + " path=" + session_path); diff --git a/src/lib/uri.cpp b/src/lib/uri.cpp index 7c79373..d89a6dd 100644 --- a/src/lib/uri.cpp +++ b/src/lib/uri.cpp @@ -877,6 +877,39 @@ void session_destroy(String session_name) } } +String csrf_session_key(String token_name) +{ + token_name = first(trim(token_name), "csrf_token"); + return("_csrf_" + token_name); +} + +String csrf_token(String session_name, String token_name) +{ + session_start(session_name); + String key = csrf_session_key(token_name); + if(context->session[key] == "") + { + String random = random_bytes(32); + if(random.size() != 32) + random = session_id_create(); + context->session[key] = sha256_hex(random); + } + return(context->session[key]); +} + +bool csrf_valid(String submitted_token, String session_name, String token_name) +{ + session_start(session_name); + String expected = context->session[csrf_session_key(token_name)]; + return(expected != "" && submitted_token != "" && crypto_equal(submitted_token, expected)); +} + +void csrf_rotate(String session_name, String token_name) +{ + session_start(session_name); + context->session.erase(csrf_session_key(token_name)); +} + String ws_make_accept_key(String client_key) { return(base64_encode(gen_sha1( diff --git a/src/lib/uri.h b/src/lib/uri.h index cc1c7cf..904584f 100644 --- a/src/lib/uri.h +++ b/src/lib/uri.h @@ -33,6 +33,9 @@ StringMap load_session_data(String session_id); void save_session_data(String session_id, StringMap data); String session_start(String session_name = "uce-session"); void session_destroy(String session_name = "uce-session"); +String csrf_token(String session_name = "uce-session", String token_name = "csrf_token"); +bool csrf_valid(String submitted_token, String session_name = "uce-session", String token_name = "csrf_token"); +void csrf_rotate(String session_name = "uce-session", String token_name = "csrf_token"); String ws_make_accept_key(String client_key); bool ws_is_valid_client_key(String client_key); String ws_encode_frame(String payload, u8 opcode = 0x1, bool is_final_fragment = true);