tina4ruby 3.13.137 → 3.13.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
data/lib/tina4/graphql.rb CHANGED
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: false
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
10
+ require_relative "parse_json"
4
11
 
5
12
  module Tina4
6
13
  # Lightweight, zero-dependency GraphQL implementation for Tina4 Ruby.
@@ -140,9 +147,15 @@ module Tina4
140
147
  # ── Queries ──
141
148
 
142
149
  # Single record: user(id: ID!): User
150
+ # The row an ID argument addresses, or nil. The ID is bound, never
151
+ # interpolated, and a non-integer ID on an integer key addresses nothing.
152
+ find_addressed = lambda do |raw_id|
153
+ pk_value = klass.respond_to?(:coerce_primary_key) ? klass.coerce_primary_key(raw_id) : raw_id
154
+ pk_value.nil? ? nil : klass.find_by_id(pk_value)
155
+ end
156
+
143
157
  add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
144
- record = klass.find_by_id(args[pk_field])
145
- record&.to_hash
158
+ find_addressed.call(args[pk_field])&.to_hash
146
159
  end
147
160
 
148
161
  # List: users(limit: Int, offset: Int): [User]
@@ -163,8 +176,10 @@ module Tina4
163
176
 
164
177
  # Update
165
178
  add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
166
- record = klass.find_by_id(args[pk_field])
167
- return nil unless record
179
+ record = find_addressed.call(args[pk_field])
180
+ # `next`, not `return`: this block outlives from_orm, so a `return` here
181
+ # raised LocalJumpError (an internal error) for a non-matching id.
182
+ next nil unless record
168
183
  (args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
169
184
  record.save
170
185
  record.to_hash
@@ -172,8 +187,8 @@ module Tina4
172
187
 
173
188
  # Delete
174
189
  add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
175
- record = klass.find_by_id(args[pk_field])
176
- return false unless record
190
+ record = find_addressed.call(args[pk_field])
191
+ next false unless record
177
192
  record.delete
178
193
  true
179
194
  end
@@ -212,13 +227,19 @@ module Tina4
212
227
  @source = source
213
228
  @tokens = tokenize(source)
214
229
  @pos = 0
230
+ # F2: bound the parser's own recursion. A deeply nested query
231
+ # ("{a{a{a...}}}") otherwise recurses in parse_selection_set until Ruby
232
+ # raises SystemStackError — which is NOT a StandardError, so the public
233
+ # execute's `rescue => e` misses it and it escapes as a 500. Raising a
234
+ # GraphQLError (a StandardError) at the depth bound keeps it caught and
235
+ # returned as a clean GraphQL error. Reuses TINA4_GRAPHQL_MAX_DEPTH.
236
+ @depth = 0
237
+ @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
215
238
  end
216
239
 
217
240
  def parse
218
241
  document = { kind: :document, definitions: [] }
219
242
  while current
220
- skip(:comma)
221
- break unless current
222
243
  document[:definitions] << parse_definition
223
244
  end
224
245
  document
@@ -234,8 +255,10 @@ module Tina4
234
255
  while i < src.length
235
256
  ch = src[i]
236
257
 
237
- # Skip whitespace
238
- if ch =~ /\s/
258
+ # Skip whitespace. Commas are insignificant in GraphQL (spec 2.1.7) and
259
+ # are skipped with it; emitting them as punctuation made every
260
+ # comma-separated argument or field list a parse error.
261
+ if ch =~ /\s/ || ch == ","
239
262
  i += 1
240
263
  next
241
264
  end
@@ -247,7 +270,7 @@ module Tina4
247
270
  end
248
271
 
249
272
  # Punctuation
250
- if "{}()[]!:=@$,".include?(ch)
273
+ if "{}()[]!:=@$".include?(ch)
251
274
  tokens << Token.new(:punct, ch, i)
252
275
  i += 1
253
276
  next
@@ -355,10 +378,6 @@ module Tina4
355
378
  advance
356
379
  end
357
380
 
358
- def skip(type, value = nil)
359
- match(type, value) while current && current.type == type && (value.nil? || current.value == value)
360
- end
361
-
362
381
  # ── Parse rules ──
363
382
 
364
383
  def parse_definition
@@ -401,8 +420,6 @@ module Tina4
401
420
  expect(:punct, "(")
402
421
  vars = []
403
422
  until current&.value == ")"
404
- skip(:comma)
405
- break if current&.value == ")"
406
423
  expect(:punct, "$")
407
424
  vname = expect(:name).value
408
425
  expect(:punct, ":")
@@ -430,11 +447,22 @@ module Tina4
430
447
  end
431
448
 
432
449
  def parse_selection_set
450
+ @depth += 1
451
+ if @max_depth.positive? && @depth > @max_depth
452
+ @depth -= 1
453
+ raise GraphQLError, "Query exceeds maximum depth of #{@max_depth}"
454
+ end
455
+ begin
456
+ parse_selection_set_inner
457
+ ensure
458
+ @depth -= 1
459
+ end
460
+ end
461
+
462
+ def parse_selection_set_inner
433
463
  expect(:punct, "{")
434
464
  selections = []
435
465
  until current&.value == "}"
436
- skip(:comma)
437
- break if current&.value == "}"
438
466
 
439
467
  if current&.type == :spread
440
468
  selections << parse_fragment_spread
@@ -496,8 +524,6 @@ module Tina4
496
524
  expect(:punct, "(")
497
525
  args = {}
498
526
  until current&.value == ")"
499
- skip(:comma)
500
- break if current&.value == ")"
501
527
  arg_name = expect(:name).value
502
528
  expect(:punct, ":")
503
529
  args[arg_name] = parse_value
@@ -547,8 +573,6 @@ module Tina4
547
573
  expect(:punct, "[")
548
574
  items = []
549
575
  until current&.value == "]"
550
- skip(:comma)
551
- break if current&.value == "]"
552
576
  items << parse_value
553
577
  end
554
578
  expect(:punct, "]")
@@ -559,8 +583,6 @@ module Tina4
559
583
  expect(:punct, "{")
560
584
  obj = {}
561
585
  until current&.value == "}"
562
- skip(:comma)
563
- break if current&.value == "}"
564
586
  key = expect(:name).value
565
587
  expect(:punct, ":")
566
588
  obj[key] = parse_value
@@ -598,14 +620,47 @@ module Tina4
598
620
  class GraphQLExecutor
599
621
  # max_depth bounds selection-set nesting (DoS / stack-overflow guard).
600
622
  # Threaded down from the owning GraphQL instance; <= 0 disables the guard.
601
- attr_accessor :max_depth
623
+ attr_accessor :max_depth, :max_nodes
602
624
 
603
- def initialize(schema, max_depth: 50)
625
+ def initialize(schema, max_depth: 50, max_nodes: 1000)
604
626
  @schema = schema
605
627
  @max_depth = max_depth
628
+ # F2: maximum expanded selection nodes per query. The depth guard bounds
629
+ # NESTING but not WIDTH — a fragment bomb stays shallow while expanding to
630
+ # millions of fields, and an alias explosion is not deep at all. This
631
+ # budget bounds the expanded selection tree (fragments expanded, each
632
+ # alias counted) so both are rejected before any resolver runs.
633
+ @max_nodes = max_nodes
606
634
  end
607
635
 
608
- def execute(document, variables: {}, context: {}, operation_name: nil)
636
+ # F2: count the expanded selection nodes, aborting past max_nodes. Fragment
637
+ # spreads are expanded (so a fragment bomb is counted, not the small
638
+ # unexpanded document) and every field — including each alias — counts once.
639
+ # Capped by max_nodes and by the depth cap, so a circular fragment trips the
640
+ # budget instead of looping and the check can never cost more than the budget.
641
+ def query_complexity(selections, fragments, depth, count)
642
+ depth_cap = (@max_depth && @max_depth.positive?) ? @max_depth : 1000
643
+ return count if depth > depth_cap
644
+
645
+ selections.each do |sel|
646
+ count += 1
647
+ return count if count > @max_nodes
648
+
649
+ case sel[:kind]
650
+ when :fragment_spread
651
+ frag = fragments[sel[:name]]
652
+ count = query_complexity(frag[:selection_set], fragments, depth + 1, count) if frag
653
+ when :inline_fragment
654
+ count = query_complexity(sel[:selection_set] || [], fragments, depth + 1, count)
655
+ else
656
+ count = query_complexity(sel[:selection_set], fragments, depth + 1, count) if sel[:selection_set]
657
+ end
658
+ return count if count > @max_nodes
659
+ end
660
+ count
661
+ end
662
+
663
+ def execute(document, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
609
664
  # Collect fragments
610
665
  fragments = {}
611
666
  operations = []
@@ -630,9 +685,28 @@ module Tina4
630
685
 
631
686
  raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
632
687
 
688
+ # F2: reject an over-complex query (fragment bomb / alias explosion) before
689
+ # any resolver runs.
690
+ if @max_nodes && @max_nodes.positive?
691
+ complexity = query_complexity(operation[:selection_set], fragments, 1, 0)
692
+ if complexity > @max_nodes
693
+ return { "data" => nil,
694
+ "errors" => [{ "message" => "Query exceeds maximum complexity of #{@max_nodes} nodes" }] }
695
+ end
696
+ end
697
+
633
698
  # Resolve variables
634
699
  resolved_vars = resolve_variables(operation[:variables], variables)
635
700
 
701
+ # F4 (CSRF): a mutation must never run over a GET. A cross-site GET (an
702
+ # <img>/<script> src, a pre-rendered link) carries the victim's cookies
703
+ # and would otherwise trigger a state change. The GET handler passes
704
+ # allow_mutations: false; POST (which a browser cannot cross-site forge
705
+ # with a JSON content-type without a CORS preflight) still allows them.
706
+ if operation[:operation] == :mutation && !allow_mutations
707
+ raise GraphQLError, "Mutations are not allowed over GET"
708
+ end
709
+
636
710
  # Choose root fields
637
711
  root_fields = case operation[:operation]
638
712
  when :query then @schema.queries
@@ -896,6 +970,15 @@ module Tina4
896
970
  @executor&.max_depth = value
897
971
  end
898
972
 
973
+ # F2 complexity budget — see GraphQLExecutor#max_nodes. Writer keeps the
974
+ # executor in sync so tests/app code can override it.
975
+ attr_reader :max_nodes
976
+
977
+ def max_nodes=(value)
978
+ @max_nodes = value
979
+ @executor&.max_nodes = value
980
+ end
981
+
899
982
  # Class-level toggle for ORM auto-schema generation. Defaults to true,
900
983
  # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
901
984
  # user app code can branch on this before calling `schema.from_orm(...)`.
@@ -968,7 +1051,10 @@ module Tina4
968
1051
  # GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
969
1052
  # legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
970
1053
  @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
971
- @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
1054
+ # F2: expanded-node (complexity) budget — bounds query WIDTH (fragment
1055
+ # bombs, alias explosions), which the depth guard does not.
1056
+ @max_nodes = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_NODES", "1000"), exception: false) || 1000
1057
+ @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth, max_nodes: @max_nodes)
972
1058
  @field_resolvers = {}
973
1059
 
974
1060
  # Drain any resolvers registered via the class-level GraphQL.resolve()
@@ -1009,10 +1095,11 @@ module Tina4
1009
1095
  end
1010
1096
 
1011
1097
  # Execute a query string directly
1012
- def execute(query, variables: {}, context: {}, operation_name: nil)
1098
+ def execute(query, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
1013
1099
  parser = GraphQLParser.new(query)
1014
1100
  document = parser.parse
1015
- @executor.execute(document, variables: variables, context: context, operation_name: operation_name)
1101
+ @executor.execute(document, variables: variables, context: context,
1102
+ operation_name: operation_name, allow_mutations: allow_mutations)
1016
1103
  rescue GraphQLError => e
1017
1104
  { "data" => nil, "errors" => [{ "message" => e.message }] }
1018
1105
  rescue => e
@@ -1057,7 +1144,7 @@ module Tina4
1057
1144
 
1058
1145
  # Handle an HTTP request body (JSON string)
1059
1146
  def handle_request(body, context: {})
1060
- payload = JSON.parse(body)
1147
+ payload = Tina4.parse_json(body)
1061
1148
  query = payload["query"] || ""
1062
1149
  variables = payload["variables"] || {}
1063
1150
  op_name = payload["operationName"]
@@ -1082,6 +1169,16 @@ module Tina4
1082
1169
 
1083
1170
  graphql = self
1084
1171
  Tina4.post path, auth: false do |request, response|
1172
+ # F4 (CSRF): require an application/json content-type. A browser can
1173
+ # only send a cross-site POST with text/plain, form-urlencoded or
1174
+ # multipart bodies without a CORS preflight; demanding JSON forces the
1175
+ # preflight (and its same-origin/allow-list check) for any cross-site
1176
+ # caller, so a forged form cannot drive a mutation. Same-origin XHR/
1177
+ # fetch and server clients set application/json and are unaffected.
1178
+ unless request.content_type.to_s.downcase.include?("application/json")
1179
+ next response.json({ "data" => nil, "errors" => [{ "message" =>
1180
+ "GraphQL requires a Content-Type of application/json" }] }, 415)
1181
+ end
1085
1182
  # handle_request expects the raw JSON text (it JSON.parses internally),
1086
1183
  # so read body_raw — request.body now returns the PARSED payload.
1087
1184
  body = request.body_raw
@@ -1089,13 +1186,15 @@ module Tina4
1089
1186
  response.json(result)
1090
1187
  end
1091
1188
 
1092
- # Optional: GET for GraphiQL/introspection
1189
+ # Optional: GET for GraphiQL/introspection. Queries only — a mutation over
1190
+ # GET is refused (F4), since a cross-site GET carries the victim's cookies.
1093
1191
  Tina4.get path, auth: false do |request, response|
1094
1192
  query = request.query["query"]
1095
1193
  if query
1096
1194
  variables = request.query["variables"]
1097
- variables = JSON.parse(variables) if variables.is_a?(String) && !variables.empty?
1098
- result = graphql.execute(query, variables: variables || {}, context: { request: request })
1195
+ variables = Tina4.parse_json(variables) if variables.is_a?(String) && !variables.empty?
1196
+ result = graphql.execute(query, variables: variables || {},
1197
+ context: { request: request }, allow_mutations: false)
1099
1198
  response.json(result)
1100
1199
  else
1101
1200
  response.html(graphiql_html(path))
data/lib/tina4/health.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
 
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Marker for trusted, pre-sanitised HTML that must render UNESCAPED.