Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d910ea5
fix(node): Bound GraphQL query cost.
euxaristia Sep 7, 2026
1d1ec25
test(node): Pin the GraphQL depth boundary.
euxaristia Sep 7, 2026
e77bfda
fix(node): Charge GraphQL mutations and list sizes against query limits.
euxaristia Sep 9, 2026
c975747
fix(node): paginate visible GraphQL repository lists
euxaristia Sep 9, 2026
9d38e04
fix(node): charge subscription roots and test query boundaries
euxaristia Sep 9, 2026
ebafeae
test(graphql): verify unauthorized caller, legacy bound, and producti…
euxaristia Sep 10, 2026
99426ca
fix(graphql): serve the advertised repository page range
euxaristia Sep 12, 2026
0e5ea50
test(graphql): cover every annotated root cost
euxaristia Sep 12, 2026
8d665c7
test(graphql): isolate the repository cursor length guard
euxaristia Sep 12, 2026
9d53e13
fix(node): pin GraphQL introspection depth and pagination contracts
euxaristia Sep 12, 2026
a06cb43
test(graphql): anti-correlate legacy repos sort and pin keyset tuple …
euxaristia Sep 13, 2026
e91b1ea
test(graphql): pin normalized owner ordering across page cursors
euxaristia Sep 14, 2026
f564ec0
test(node): make Windows regression fixtures portable
euxaristia Sep 14, 2026
1de5eb9
fix(sync): use a supported Windows promisor blob filter
euxaristia Sep 14, 2026
e9998a7
test(graphql): anti-correlate pagination ids and cover full terminal …
euxaristia Sep 15, 2026
ac6c134
revert(sync): split Windows promisor filter out of GraphQL query cost PR
euxaristia Sep 15, 2026
4e6488d
test(graphql): pin case ordering and test documented max page limit
euxaristia Sep 17, 2026
f778793
fix(db): pin COLLATE "C" for visible repos page ordering and keyset r…
euxaristia Sep 18, 2026
c0aa2dc
fix(db): deny malformed reader lists without failing repo pages
euxaristia Sep 20, 2026
a80845e
fix(graphql): reject NUL bytes in repository cursors
euxaristia Sep 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 188 additions & 2 deletions crates/gitlawb-node/src/graphql/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ pub mod query;
pub mod subscription;
pub mod types;

use async_graphql::Schema;
use async_graphql::{Schema, SchemaBuilder};
use std::sync::Arc;

use crate::db::Db;
Expand All @@ -14,6 +14,21 @@ use subscription::SubscriptionRoot;

pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;

// Keep ordinary schema discovery and application queries usable while
// bounding validation work and the number of resolver selections per request.
const GRAPHQL_MAX_COMPLEXITY: usize = 400;
// The current public schema is shallow; this leaves headroom for composed
// clients without allowing recursively nested documents to grow unchecked.
const GRAPHQL_MAX_DEPTH: usize = 12;

fn apply_query_limits<Query, Mutation, Subscription>(
builder: SchemaBuilder<Query, Mutation, Subscription>,
) -> SchemaBuilder<Query, Mutation, Subscription> {
builder
.limit_complexity(GRAPHQL_MAX_COMPLEXITY)
.limit_depth(GRAPHQL_MAX_DEPTH)
}

/// Client-facing message for GraphQL resolver failures that wrap a real
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
/// detail in the GraphQL `errors` array (#250).
Expand Down Expand Up @@ -81,7 +96,7 @@ pub fn build_schema(
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
task_event_tx: tokio::sync::broadcast::Sender<TaskEventBroadcast>,
) -> GitlawbSchema {
Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
apply_query_limits(Schema::build(QueryRoot, MutationRoot, SubscriptionRoot))
.data(db)
.data(ref_update_tx)
.data(task_event_tx)
Expand All @@ -91,6 +106,8 @@ pub fn build_schema(
#[cfg(test)]
mod tests {
use super::*;
use async_graphql::{EmptyMutation, EmptySubscription, Object, Value};
use std::sync::atomic::{AtomicUsize, Ordering};

#[test]
fn graphql_db_err_opaques_sqlx_chain() {
Expand Down Expand Up @@ -160,6 +177,175 @@ mod tests {
assert!(!err.message.contains("failed to open"));
}

fn production_test_schema() -> GitlawbSchema {
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://localhost/unused")
.unwrap();
build_schema(
Arc::new(Db::for_testing(pool)),
tokio::sync::broadcast::channel(1).0,
tokio::sync::broadcast::channel(1).0,
)
}

#[tokio::test]
async fn production_limits_reject_mutation_aliases_and_large_lists() {
let schema = production_test_schema();
for field in [
"claimTask(id: \"missing\", assigneeDid: \"did:key:test\") { id }",
"refUpdates(limit: 200) { repo }",
"tasks(limit: 200) { id }",
] {
let count = if field.starts_with("claim") { 8 } else { 2 };
let fields = (0..count)
.map(|n| format!("r{n}: {field}"))
.collect::<Vec<_>>()
.join(" ");
let prefix = if field.starts_with("claim") {
"mutation"
} else {
"query"
};
let response = schema.execute(format!("{prefix} {{ {fields} }}")).await;
assert_eq!(response.errors.len(), 1, "{:?}", response.errors);
assert_eq!(response.errors[0].message, "Query is too complex.");
}
}

#[tokio::test]
async fn seven_root_aliases_are_accepted() {
struct Root(Arc<AtomicUsize>);
#[Object]
impl Root {
#[graphql(complexity = "50 + child_complexity")]
async fn repos(&self) -> Vec<Nested> {
self.0.fetch_add(1, Ordering::Relaxed);
vec![Nested]
}
}
let calls = Arc::new(AtomicUsize::new(0));
let schema = apply_query_limits(Schema::build(
Root(calls.clone()),
EmptyMutation,
EmptySubscription,
))
.finish();
let fields = (0..7)
.map(|n| format!("r{n}: repos {{ value }}"))
.collect::<Vec<_>>()
.join(" ");
let response = schema.execute(format!("{{ {fields} }}")).await;
assert!(response.errors.is_empty(), "{:?}", response.errors);
assert_eq!(calls.load(Ordering::Relaxed), 7);
}

#[tokio::test]
async fn expensive_root_aliases_are_rejected_before_database_access() {
// Use the production builder with a lazy pool: rejection must precede DB access.
let schema = production_test_schema();
let fields = (0..8)
.map(|n| format!("r{n}: repos {{ name }}"))
.collect::<Vec<_>>()
.join(" ");

let response = schema.execute(format!("{{ {fields} }}")).await;

assert_eq!(response.data, Value::Null);
assert_eq!(response.errors.len(), 1);
assert_eq!(response.errors[0].message, "Query is too complex.");
}

#[tokio::test]
async fn ordinary_schema_introspection_remains_available() {
let schema =
apply_query_limits(Schema::build(QueryRoot, EmptyMutation, EmptySubscription)).finish();
let response = schema
.execute(
r#"
query IntrospectionQuery {
__schema {
queryType {
name
fields {
name
type {
kind
name
ofType { kind name }
}
}
}
}
}
"#,
)
.await;

assert!(
response.errors.is_empty(),
"graphql errors: {:?}",
response.errors
);
assert_ne!(response.data, Value::Null);
}

#[derive(Clone, Copy)]
struct Nested;

#[Object]
impl Nested {
async fn child(&self) -> Nested {
Nested
}

async fn value(&self) -> i32 {
1
}
}

struct CountingQuery(Arc<AtomicUsize>);

#[Object]
impl CountingQuery {
async fn nested(&self) -> Nested {
self.0.fetch_add(1, Ordering::Relaxed);
Nested
}
}

#[tokio::test]
async fn query_depth_limit_accepts_twelve_and_rejects_thirteen() {
let calls = Arc::new(AtomicUsize::new(0));
let schema = apply_query_limits(Schema::build(
CountingQuery(Arc::clone(&calls)),
EmptyMutation,
EmptySubscription,
))
.finish();
let query_at_depth = |depth: usize| {
let selection = (2..depth).fold("value".to_string(), |selection, _| {
format!("child {{ {selection} }}")
});
format!("{{ nested {{ {selection} }} }}")
};

let accepted = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH)).await;

assert!(
accepted.errors.is_empty(),
"depth {GRAPHQL_MAX_DEPTH} should be accepted: {:?}",
accepted.errors
);
assert_eq!(calls.swap(0, Ordering::Relaxed), 1);

let rejected = schema.execute(query_at_depth(GRAPHQL_MAX_DEPTH + 1)).await;

assert_eq!(rejected.data, Value::Null);
assert_eq!(rejected.errors.len(), 1);
assert_eq!(rejected.errors[0].message, "Query is nested too deep.");
assert_eq!(calls.load(Ordering::Relaxed), 0);
}

/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
/// through the opaque helpers, or discard the error (`|_|`). Same source-
/// scrape pattern as `api::authz_guard` (#255 review).
Expand Down
4 changes: 4 additions & 0 deletions crates/gitlawb-node/src/graphql/mutation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct MutationRoot;

#[Object]
impl MutationRoot {
#[graphql(complexity = "50 + child_complexity")]
async fn create_task(
&self,
ctx: &Context<'_>,
Expand Down Expand Up @@ -58,6 +59,7 @@ impl MutationRoot {
Ok(AgentTaskType::from(task))
}

#[graphql(complexity = "50 + child_complexity")]
async fn claim_task(
&self,
ctx: &Context<'_>,
Expand Down Expand Up @@ -87,6 +89,7 @@ impl MutationRoot {
Ok(AgentTaskType::from(task))
}

#[graphql(complexity = "50 + child_complexity")]
async fn complete_task(
&self,
ctx: &Context<'_>,
Expand Down Expand Up @@ -129,6 +132,7 @@ impl MutationRoot {
Ok(AgentTaskType::from(task))
}

#[graphql(complexity = "50 + child_complexity")]
async fn fail_task(
&self,
ctx: &Context<'_>,
Expand Down
6 changes: 6 additions & 0 deletions crates/gitlawb-node/src/graphql/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ pub struct QueryRoot;

#[Object]
impl QueryRoot {
// DB-backed roots carry a base cost so aliases consume the request budget
// even when each alias selects only one inexpensive response field.
#[graphql(complexity = "50 + child_complexity")]
async fn repos(&self, ctx: &Context<'_>) -> Result<Vec<RepoType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
let repos = db
Expand Down Expand Up @@ -45,6 +48,7 @@ impl QueryRoot {
.collect())
}

#[graphql(complexity = "50 + (limit.clamp(0, 200) as usize) * child_complexity")]
async fn ref_updates(
&self,
ctx: &Context<'_>,
Expand Down Expand Up @@ -105,6 +109,7 @@ impl QueryRoot {
Ok(resolved)
}

#[graphql(complexity = "50 + (limit.clamp(0, 200) as usize) * child_complexity")]
async fn tasks(
&self,
ctx: &Context<'_>,
Expand All @@ -128,6 +133,7 @@ impl QueryRoot {
Ok(tasks.into_iter().map(AgentTaskType::from).collect())
}

#[graphql(complexity = "50 + child_complexity")]
async fn task(&self, ctx: &Context<'_>, id: String) -> Result<Option<AgentTaskType>> {
let db = ctx.data_unchecked::<Arc<Db>>();
let t = db
Expand Down
Loading