diff --git a/AGENTS.md b/AGENTS.md index 118496b1..33796920 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,95 @@ -Core Concept: User writes .sql files, SQLPage executes queries, results mapped to handlebars UI components, -HTML streamed to client +# SQLPage architecture + +SQLPage is an SQL-only web application builder and web server. An application is primarily a set of `.sql` +files: SQLPage routes an HTTP request to a file, executes its statements against a database, interprets rows +whose `component` column names a UI component, and streams the resulting HTML (or another response) to the +client. It is intended for fast, data-centric applications while still allowing custom HTML, CSS, and +JavaScript where needed. + +## Features and repository layout + +- **Application entry point and configuration** (`src/main.rs`, `src/lib.rs`, `src/app_config.rs`, `src/cli/`). + The executable starts the server; application state, configuration, environment variables, and command-line + handling are defined here. `configuration.md` documents the user-facing settings. +- **SQL semantics and execution** (`src/webserver/database/`). SQLPage uses the database's SQL for selects, joins, aggregation, inserts, updates, + deletes, transactions, JSON processing, and database-specific features. It parses SQL, recognizes SQLPage + extensions, binds request values safely, and sends ordinary SQL to the selected database. SQL files contain + sequential statements; result sets become component invocations in response order. `SET` assigns a value + to a mutable SQLPage variable and is useful for reusing query results or controlling later statements. +- **Request variables** (`src/webserver/request_variables.rs`, `src/webserver/http_request_info.rs`, + `src/webserver/database/syntax_tree.rs`). `?name` refers to a URL/GET parameter, `:name` explicitly refers to a form/POST + value, and `$name` is the compatibility shorthand that uses a POST value when present and otherwise a GET + value (a SET variable takes precedence where applicable). Values are passed as parameters, not interpolated + into SQL. GET and POST variables are request inputs; SET variables are mutable during request execution. + `sqlpage.variables()` exposes them as JSON, with SET > POST > GET precedence. +- **SQLPage functions** (`src/webserver/database/sqlpage_functions/`). Calls such as `sqlpage.fetch`, `sqlpage.run_sql`, `sqlpage.set_variable`, file + readers, hashing/HMAC helpers, request metadata, uploads, headers/cookies, URL helpers, OIDC user info, + and HTTP fetch are registered in `src/webserver/database/sqlpage_functions/functions.rs`. Functions can + return values, alter response/request state, include another SQL file, or raise an error. `sqlpage.exec` + is deliberately disabled by default because it runs server processes. +- **Database support and pooling** (`src/webserver/database/connect.rs`, `execute_queries.rs`, `migrations.rs`). + Native drivers support SQLite, PostgreSQL, MySQL, and Microsoft SQL + Server; the ODBC driver provides access to other ODBC-compatible databases. SQLPage uses `sqlx` and a + reusable connection pool, with configurable maximum connections, idle/lifetime timeouts, acquire timeout, + retries, and optional `on_connect.sql`/`on_reset.sql` hooks. Database-specific SQL should be isolated or + covered by the relevant database tests. +- **Rendering and components** (`src/render.rs`, `src/templates.rs`, `src/dynamic_component.rs`, + `src/template_helpers.rs`, `sqlpage/templates/`, `sqlpage/sqlpage.css`, `sqlpage/sqlpage.js`). Built-in components live in `sqlpage/templates/*.handlebars` and cover shells, text, + tables, lists, cards, charts, forms, navigation, modals, downloads, maps, and more. Query columns map to + component properties; nested/dynamic components and `sqlpage.run_sql` support composition and lazy loading. + Custom Handlebars components can be placed in the configured `sqlpage/templates` directory. Raw HTML and + custom assets are possible through the HTML/shell components. Rendering is streamed so the response can + start while later query results are still being processed. +- **Control flow and errors** (`src/webserver/error.rs`, `error_with_status.rs`, `routing.rs`, + `src/default_404.sql`). SQL remains declarative: use predicates, `CASE`, `SET`, component rows, and + the `redirect` component to conditionally continue, redirect, or implement guards/error pages. There is no + general SQLPage `IF` statement. Parse, database, function, component, and response errors are converted to + contextual HTTP errors; `default_404.sql` handles missing routes. Do not hide errors by changing unrelated + error handling or tests. +- **HTTP server and client** (`src/webserver/http.rs`, `http_client.rs`, `response_writer.rs`, `static_content.rs`, + `https.rs`, `content_security_policy.rs`, `server_timing.rs`). The server is built on Actix Web, supports normal HTTP request/response + handling, streaming, uploads, static assets, HTTP/2, HTTPS, and optional Unix sockets/serverless adapters. + The shared outbound client is used by HTTP-fetch and OIDC integrations and honors configured/native TLS + certificates and timeouts. Content-security-policy and response/header helpers are part of the request + pipeline. +- **OIDC** (`src/webserver/oidc.rs`, `src/webserver/database/sqlpage_functions/functions/user_info.rs`). + Optional OpenID Connect middleware protects configured path prefixes, performs provider discovery, + login/callback/logout, validates tokens, maintains an authenticated cookie, and exposes identity claims to + SQLPage functions. Configuration is in `AppConfig`/`configuration.md`; public paths can be excluded. +- **Caching and files** (`src/filesystem.rs`, `src/file_cache.rs`, `src/telemetry*.rs`). Parsed SQL files are cached. Files may come from the web root/filesystem or from the + database-backed `sqlpage_files` store, and templates/migrations are loaded from the configuration directory. + Telemetry, request timing, and debug logging help diagnose query, pool, and rendering performance. + +- **Examples, tests, and project operations** (`examples/`, `tests/`, `configuration.md`, `CONTRIBUTING.md`, + `README.md`, `.github/workflows/ci.yml`). Examples include the official documentation site and its migrations; + tests cover SQL fixtures, database variants, uploads, OIDC, and server timing. The contribution guide and CI + workflow define the development and validation conventions. +- **Deployment and local infrastructure** (`Dockerfile`, `lambda.Dockerfile`, `docker-compose.yml`, + `sqlpage.service`). These provide container, serverless, local database-testing, and service deployment support. + +## Documentation and release notes + +The official documentation site is itself an SQLPage application in `examples/official-site/`. Its database +schema and documentation content are created by the SQL migrations in +`examples/official-site/sqlpage/migrations/`; the site is recreated from scratch during deployment. Existing +official-site migrations are editable source files: update the migration that already documents a component, +function, configuration option, or feature in place. Do not create a new migration merely to update existing +documentation. Add a new migration only for genuinely new documentation content when that is the established +pattern for the relevant area. + +- Add or update a component's row, parameters, and examples in the component documentation migrations when + changing `sqlpage/templates/` or component behavior. +- Add or update a function's description, parameters, examples, and caveats in the function documentation + migrations when changing `sqlpage_functions` or any `sqlpage.*` function behavior. +- Update the relevant configuration documentation when adding or changing `AppConfig` settings, environment + variables, defaults, authentication behavior, HTTP/TLS behavior, database settings, or custom components. +- Document OIDC changes in the authentication/OIDC migrations, including configuration requirements, exposed + claims/functions, login/logout behavior, and security implications. +- Document other user-visible behavior—SQL syntax extensions, variables, control flow, errors, uploads, + rendering, HTTP endpoints, performance, or deployment—in the corresponding official-site SQL page or + migration. Follow nearby migrations and keep examples executable and database-portable where possible. +- Update `CHANGELOG.md` for user-visible changes, bug fixes, breaking changes, deprecations, and noteworthy + internal changes. Keep the entry concise and use the existing version/section conventions. ## Validation @@ -52,4 +142,4 @@ official documentation website sql tables: - [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs) - Routing: file-based in `src/webserver/routing.rs`. Missing paths use the nearest ancestor `404.sql`; without one, HTML uses `src/default_404.sql` and other formats receive a plain-text 404. - Follow patterns from similar modules before introducing new abstractions. -- Frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js). +- frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js) diff --git a/CHANGELOG.md b/CHANGELOG.md index e34edb41..d8dc8b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `SELECT sqlpage.random_string(8) AS token FROM many_rows` now produces one token per row instead of one token reused across rows - `SET result = (SELECT sqlpage.fetch($url) WHERE NOT $cached)` now works as expected and does not re-fetch a cached result. Apps relying on sqlite-specific `SET x = (SELECT y FROM t)` first-row behavior should add `LIMIT 1` and select exactly one column. - **Access logs now go to stdout.** SQLPage now writes the single per-request completion log line to stdout with the target `sqlpage::access`, matching common application-server and container logging conventions. Diagnostic logs, warnings, and internal errors still go to stderr. If your `LOG_LEVEL` or `RUST_LOG` filter is scoped to a specific old target such as `sqlpage::webserver::http=info`, add `sqlpage::access=info` so request-completion logs are still emitted. If your log pipeline only collects stderr, update it to collect stdout too. +- **OIDC redirects are no longer cacheable.** Authorization redirects contain one-time state and post-login redirects set session cookies. SQLPage now sends `Cache-Control: no-store` for these responses, preventing a browser or intermediary from replaying an expired authorization redirect. ## v0.44.1 diff --git a/src/webserver/oidc.rs b/src/webserver/oidc.rs index 3c98e03e..195fe9e1 100644 --- a/src/webserver/oidc.rs +++ b/src/webserver/oidc.rs @@ -870,6 +870,9 @@ fn build_auth_provider_redirect_response( .finish(); let mut response = HttpResponse::SeeOther(); response.append_header((header::LOCATION, url.to_string())); + // The location contains a one-time CSRF state. A cached redirect would + // replay it after its state cookie has been consumed. + response.append_header((header::CACHE_CONTROL, "no-store")); if let Ok(cookies) = request.cookies() { for mut cookie in get_tmp_login_flow_state_cookies_to_evict(&cookies).cloned() { cookie.make_removal(); @@ -884,6 +887,7 @@ fn build_auth_provider_redirect_response( fn build_redirect_response(target_url: String) -> HttpResponse { HttpResponse::SeeOther() .append_header(("Location", target_url)) + .append_header((header::CACHE_CONTROL, "no-store")) .body("Redirecting...") } diff --git a/tests/oidc/mod.rs b/tests/oidc/mod.rs index af32ad22..7e605353 100644 --- a/tests/oidc/mod.rs +++ b/tests/oidc/mod.rs @@ -264,6 +264,17 @@ fn get_query_param(url: &Url, name: &str) -> String { .to_string() } +fn permits_storage_after_proxy_adds_freshness(headers: &header::HeaderMap) -> bool { + // Reproduces https://github.com/sqlpage/SQLPage/issues/1341, where an + // intermediary added `Cache-Control: max-age=86400` to OIDC 303 responses. + // `no-store` must still prevent browser storage when both directives exist. + !headers + .get_all(header::CACHE_CONTROL) + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|directive| directive.trim().eq_ignore_ascii_case("no-store")) +} + macro_rules! request_with_cookies { ($app:expr, $req:expr, $cookies:expr) => {{ let mut req = $req; @@ -325,6 +336,68 @@ async fn setup_oidc_test( (app, provider) } +#[actix_web::test] +async fn test_oidc_cached_authorization_redirect_cannot_replay_consumed_state() { + let (app, provider) = setup_oidc_test(|_| {}).await; + let mut cookies: Vec> = Vec::new(); + + // Reproduces https://github.com/sqlpage/SQLPage/issues/1341: Chrome's + // private cache can replay a cached 303 without reapplying Set-Cookie + // headers, causing the browser to retry an already-consumed OIDC state. + let initial_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); + let initial_auth_url = Url::parse( + initial_response + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap(), + ) + .unwrap(); + let cached_authorization_url = + permits_storage_after_proxy_adds_freshness(initial_response.headers()) + .then_some(initial_auth_url.clone()); + + let state = get_query_param(&initial_auth_url, "state"); + let nonce = get_query_param(&initial_auth_url, "nonce"); + let redirect_uri = get_query_param(&initial_auth_url, "redirect_uri"); + let callback_path = Url::parse(&redirect_uri).unwrap().path().to_owned(); + provider.store_auth_code("first-code".to_string(), nonce.clone()); + let callback_uri = format!("{callback_path}?code=first-code&state={state}"); + let callback_response = + request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); + assert_eq!(callback_response.status(), StatusCode::SEE_OTHER); + + if let Some(stale_authorization_url) = cached_authorization_url { + // A fresh Entra code is returned for the authorization URL cached by + // Chrome, but its state is the state that the successful callback just + // consumed. Before this fix, SQLPage restarted OIDC here, creating the + // observed redirect loop. + let stale_state = get_query_param(&stale_authorization_url, "state"); + provider.store_auth_code("stale-code".to_string(), nonce); + let stale_callback_uri = format!("{callback_path}?code=stale-code&state={stale_state}"); + let stale_callback_response = request_with_cookies!( + app, + test::TestRequest::get().uri(&stale_callback_uri), + cookies + ); + panic!( + "a cacheable authorization redirect replays a consumed state; stale callback redirected to {}", + stale_callback_response + .headers() + .get(header::LOCATION) + .unwrap() + .to_str() + .unwrap() + ); + } + + // With `no-store`, Chrome re-requests `/` after login and sends its new + // auth cookie instead of following the original authorization redirect. + let final_response = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); + assert_eq!(final_response.status(), StatusCode::OK); +} + #[actix_web::test] async fn test_oidc_happy_path() { let (app, provider) = setup_oidc_test(|_| {}).await; @@ -332,6 +405,11 @@ async fn test_oidc_happy_path() { let resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + resp.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "the authorization redirect contains a one-time state and must not be cached" + ); let auth_url = Url::parse(resp.headers().get("location").unwrap().to_str().unwrap()).unwrap(); let state = get_query_param(&auth_url, "state"); @@ -347,6 +425,11 @@ async fn test_oidc_happy_path() { let callback_resp = request_with_cookies!(app, test::TestRequest::get().uri(&callback_uri), cookies); assert_eq!(callback_resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + callback_resp.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "the post-login redirect must not re-enter a cached authorization redirect" + ); let final_resp = request_with_cookies!(app, test::TestRequest::get().uri("/"), cookies); assert_eq!(final_resp.status(), StatusCode::OK);