Skip to content
This repository was archived by the owner on Mar 16, 2026. It is now read-only.

Commit d625638

Browse files
jainejaine
authored andcommitted
hardening: jsonschema auto, fuzz additions, stricter schemas
1 parent dfc6e06 commit d625638

9 files changed

Lines changed: 108 additions & 16 deletions

File tree

ao/access/process.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local ids = require("ao.shared.ids")
66
local auth = require("ao.shared.auth")
77
local idem = require("ao.shared.idempotency")
88
local audit = require("ao.shared.audit")
9+
local schema = require("ao.shared.schema")
910
local metrics = require("ao.shared.metrics")
1011

1112
local handlers = {}
@@ -78,6 +79,8 @@ function handlers.GrantEntitlement(msg)
7879
if not ok_len_asset then return codec.error("INVALID_INPUT", err_asset, { field = "Asset" }) end
7980
local ok_len_policy, err_policy = validation.check_length(msg.Policy, 64, "Policy")
8081
if not ok_len_policy then return codec.error("INVALID_INPUT", err_policy, { field = "Policy" }) end
82+
local ok_schema, schema_err = schema.validate("entitlement", { subject = msg.Subject, asset = msg.Asset, policy = msg.Policy })
83+
if not ok_schema then return codec.error("INVALID_INPUT", "Policy failed schema", { errors = schema_err }) end
8184
local policy_size = validation.estimate_json_length(msg.Policy)
8285
local ok_size, err_size = validation.check_size(policy_size, MAX_POLICY_BYTES, "Policy")
8386
if not ok_size then return codec.error("INVALID_INPUT", err_size, { field = "Policy" }) end

ao/catalog/process.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,11 @@ function handlers.UpsertProduct(msg)
145145
if msg.Payload.sku ~= msg.Sku then
146146
return codec.error("INVALID_INPUT", "Payload sku must match Sku field", { field = "Sku" })
147147
end
148-
local ok_schema, schema_err = schema.validate("product", msg.Payload)
149-
if not ok_schema then return codec.error("INVALID_INPUT", "Payload failed schema", { errors = schema_err }) end
150148
local payload_len = validation.estimate_json_length(msg.Payload)
151149
local ok_size, err_size = validation.check_size(payload_len, MAX_PAYLOAD_BYTES, "Payload")
152150
if not ok_size then return codec.error("INVALID_INPUT", err_size, { field = "Payload" }) end
151+
local ok_schema, schema_err = schema.validate("product", msg.Payload)
152+
if not ok_schema then return codec.error("INVALID_INPUT", "Payload failed schema", { errors = schema_err }) end
153153
local key = ids.product_key(msg["Site-Id"], msg.Sku)
154154
state.products[key] = { payload = msg.Payload, version = msg.Version }
155155
audit.record("catalog", "UpsertProduct", msg, nil, { sku = msg.Sku })

ao/registry/process.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ local idem = require("ao.shared.idempotency")
88
local ids = require("ao.shared.ids")
99
local audit = require("ao.shared.audit")
1010
local metrics = require("ao.shared.metrics")
11+
local schema = require("ao.shared.schema")
1112

1213
local handlers = {}
1314
local allowed_actions = {
@@ -93,6 +94,8 @@ function handlers.RegisterSite(msg)
9394
if msg.Config ~= nil then
9495
local ok_type_cfg, err_type_cfg = validation.assert_type(msg.Config, "table", "Config")
9596
if not ok_type_cfg then return codec.error("INVALID_INPUT", err_type_cfg, { field = "Config" }) end
97+
local ok_schema, schema_err = schema.validate("registryConfig", msg.Config)
98+
if not ok_schema then return codec.error("INVALID_INPUT", "Config failed schema", { errors = schema_err }) end
9699
end
97100
local config_len = validation.estimate_json_length(config)
98101
local ok_size, err_size = validation.check_size(config_len, MAX_CONFIG_BYTES, "Config")

ao/shared/arweave.lua

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ local HTTP_SIGNER_HEADER = os.getenv("ARWEAVE_HTTP_SIGNER_HEADER") or "X-Arweave
1919
local HTTP_RETRIES = tonumber(os.getenv("ARWEAVE_HTTP_RETRIES") or "3")
2020
local HTTP_BACKOFF_MS = tonumber(os.getenv("ARWEAVE_HTTP_BACKOFF_MS") or "200")
2121
local MAX_MANIFEST_BYTES = tonumber(os.getenv("ARWEAVE_MAX_MANIFEST_BYTES") or "262144") -- 256 KiB
22+
local HTTP_MAX_BODY = tonumber(os.getenv("ARWEAVE_HTTP_MAX_BODY") or "1048576") -- 1 MiB
23+
local EXPECT_RESPONSE_HASH = os.getenv("ARWEAVE_EXPECT_RESPONSE_HASH")
24+
local FORCE_ERROR = os.getenv("ARWEAVE_FORCE_ERROR") == "1"
2225

2326
local function next_tx()
2427
counter = counter + 1
@@ -206,7 +209,9 @@ if MODE == "http" then
206209
end
207210
local hash = sha256(serialized) or fallback_checksum(serialized)
208211
local httpStatus, response_path
209-
if HTTP_REAL and ENDPOINT and has_curl() and not (os.getenv("ARWEAVE_HTTP_DRYRUN") == "1") then
212+
if FORCE_ERROR then
213+
httpStatus = 500
214+
elseif HTTP_REAL and ENDPOINT and has_curl() and not (os.getenv("ARWEAVE_HTTP_DRYRUN") == "1") then
210215
if not signer_exists() then
211216
log_request(tx, {
212217
endpoint = ENDPOINT or "<missing-endpoint>",
@@ -233,12 +238,18 @@ if MODE == "http" then
233238
f:close()
234239
if #body == 0 then
235240
log_request(tx, { warning = "empty_response" })
241+
elseif HTTP_MAX_BODY and #body > HTTP_MAX_BODY then
242+
log_request(tx, { error = "response_too_large", size = #body })
243+
return nil, "http_response_too_large"
236244
else
237245
local resp_hash = sha256(body)
238246
if not resp_hash then
239247
log_request(tx, { warning = "response_hash_failed" })
240248
else
241249
log_request(tx, { responseHash = resp_hash })
250+
if EXPECT_RESPONSE_HASH and resp_hash ~= EXPECT_RESPONSE_HASH then
251+
return nil, "response_hash_mismatch"
252+
end
242253
end
243254
end
244255
end

ao/shared/schema.lua

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
-- uses that; otherwise falls back to the embedded validator below.
44

55
local Schema = {}
6-
local USE_PY = os.getenv("SCHEMA_VALIDATOR") == "python"
6+
local SCHEMA_MODE = os.getenv("SCHEMA_VALIDATOR") or "auto" -- auto|python|embedded
77

88
-- Schemas embedded as Lua tables (converted from schemas/*.json)
99
local SCHEMAS = {
@@ -55,9 +55,27 @@ local SCHEMAS = {
5555
type = "object",
5656
required = { "subject", "asset" },
5757
properties = {
58-
subject = { type = "string" },
59-
asset = { type = "string" },
60-
policy = { type = "string" },
58+
subject = { type = "string", minLength = 1, maxLength = 128 },
59+
asset = { type = "string", minLength = 1, maxLength = 256 },
60+
policy = { type = "string", minLength = 1, maxLength = 128 },
61+
},
62+
},
63+
accessAsset = {
64+
type = "object",
65+
required = { "asset", "ref" },
66+
properties = {
67+
asset = { type = "string", minLength = 1, maxLength = 256 },
68+
ref = { type = "string", minLength = 1, maxLength = 2048 },
69+
visibility = { type = "string", enum = { "protected", "public" } }
70+
},
71+
},
72+
registryConfig = {
73+
type = "object",
74+
required = {},
75+
properties = {
76+
version = { type = "string", minLength = 1, maxLength = 128 },
77+
metadata = { type = "object" },
78+
flags = { type = "object" },
6179
},
6280
},
6381
}
@@ -149,7 +167,7 @@ local function validate_against(schema, value, path, errors)
149167
end
150168

151169
function Schema.validate(schema_name, value)
152-
if USE_PY then
170+
if SCHEMA_MODE ~= "embedded" then
153171
local ok, err = Schema.validate_python(schema_name, value)
154172
if ok ~= nil then return ok, err end -- nil means fallback to embedded
155173
end
@@ -167,6 +185,10 @@ end
167185

168186
-- Python/jsonschema validator (optional). Returns nil if not usable.
169187
function Schema.validate_python(schema_name, value)
188+
local has_py = os.execute("python3 -c \"import jsonschema\" >/dev/null 2>&1")
189+
if has_py ~= true and has_py ~= 0 then
190+
return nil, "python_jsonschema_missing"
191+
end
170192
local schema_path = "schemas/" .. schema_name .. ".schema.json"
171193
local f = io.open(schema_path, "r")
172194
if not f then return nil, "schema_not_found" end
@@ -202,12 +224,15 @@ function Schema.validate_python(schema_name, value)
202224
end
203225
jf:write(json_encode(value))
204226
jf:close()
205-
local cmd = string.format("python3 - <<'PY'\nimport json,sys\ntry:\n import jsonschema\nexcept ImportError:\n sys.exit(2)\nwith open(%q) as f: schema=json.load(f)\nwith open(%q) as f: inst=json.load(f)\ntry:\n jsonschema.validate(inst, schema)\n sys.exit(0)\nexcept jsonschema.ValidationError as e:\n print(e.message)\n sys.exit(1)\nPY", schema_path, tmp)
227+
local cmd = string.format("python3 - <<'PY'\nimport json,sys,jsonschema\nwith open(%q) as f: schema=json.load(f)\nwith open(%q) as f: inst=json.load(f)\ntry:\n jsonschema.validate(inst, schema)\n sys.exit(0)\nexcept jsonschema.ValidationError:\n sys.exit(1)\nPY", schema_path, tmp)
206228
local ok = os.execute(cmd)
207229
os.remove(tmp)
208-
if ok == 0 then return true end
209-
if ok == 2 then return nil, "python_jsonschema_missing" end
210-
return false, { "python_validator_failed" }
230+
if ok == 0 or ok == true then return true end
231+
-- If validation fails, treat as schema error; otherwise fallback
232+
if ok == 256 or ok == false then
233+
return false, { "python_validator_failed" }
234+
end
235+
return nil, "python_validator_unavailable"
211236
end
212237

213238
return Schema

ao/site/process.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,11 @@ function handlers.PutDraft(msg)
156156
-- normalize content against schema expectations
157157
if not msg.Content.id then msg.Content.id = msg["Page-Id"] end
158158
if not msg.Content.blocks then msg.Content.blocks = {} end
159-
local ok_schema, schema_err = schema.validate("page", msg.Content)
160-
if not ok_schema then return codec.error("INVALID_INPUT", "Content failed schema", { errors = schema_err }) end
161159
local content_len = validation.estimate_json_length(msg.Content)
162160
local ok_size, err_size = validation.check_size(content_len, MAX_CONTENT_BYTES, "Content")
163161
if not ok_size then return codec.error("INVALID_INPUT", err_size, { field = "Content" }) end
162+
local ok_schema, schema_err = schema.validate("page", msg.Content)
163+
if not ok_schema then return codec.error("INVALID_INPUT", "Content failed schema", { errors = schema_err }) end
164164
local key = ids.page_key(msg["Site-Id"], msg["Page-Id"], "draft")
165165
state.drafts[key] = { content = msg.Content, updatedAt = os.date("!%Y-%m-%dT%H:%M:%SZ") }
166166
return codec.ok({ draftId = key })
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"siteId":"site-1","version":"v2","pages":[{"content":{"id":"home","blocks":[],"title":"Hello"},"pageId":"home"}]}
1+
{"pages":[{"pageId":"home","content":{"blocks":[],"id":"home","title":"Hello"}}],"version":"v2","siteId":"site-1"}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"siteId":"site-2","version":"v1","pages":[{"content":{"id":"old","blocks":[],"title":"Old"},"pageId":"old"}]}
1+
{"pages":[{"pageId":"old","content":{"blocks":[],"id":"old","title":"Old"}}],"version":"v1","siteId":"site-2"}

scripts/verify/fuzz.lua

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local catalog = require("ao.catalog.process")
66
local site = require("ao.site.process")
77
local ar = require("ao.shared.arweave")
88
local audit = require("ao.shared.audit")
9+
local auth = require("ao.shared.auth")
910

1011
local function with_req(fields)
1112
fields["Request-Id"] = fields["Request-Id"] or tostring(math.random())
@@ -46,6 +47,55 @@ do
4647
end
4748
end
4849

50+
-- Force Arweave HTTP error via env flag
51+
do
52+
package.loaded["ao.shared.arweave"] = nil
53+
os.setenv("ARWEAVE_MODE", "http")
54+
os.setenv("ARWEAVE_HTTP_REAL", "1")
55+
os.setenv("ARWEAVE_FORCE_ERROR", "1")
56+
local ar2 = require("ao.shared.arweave")
57+
local tx, err = ar2.put_snapshot({ dummy = "ok" })
58+
if err ~= "http_error" then
59+
error("expected http_error with force flag")
60+
end
61+
os.setenv("ARWEAVE_FORCE_ERROR", nil)
62+
package.loaded["ao.shared.arweave"] = nil
63+
end
64+
65+
-- Auth ed25519 verification round-trip
66+
do
67+
-- generate keypair
68+
os.execute("openssl genpkey -algorithm ed25519 -out /tmp/ao-ed.key >/dev/null 2>&1")
69+
os.execute("openssl pkey -in /tmp/ao-ed.key -pubout -out /tmp/ao-ed.pub >/dev/null 2>&1")
70+
local target = "PublishVersion|site-x|rid-x"
71+
os.execute(string.format("printf %%s %q > /tmp/ao-msg", target))
72+
os.execute("openssl pkeyutl -sign -inkey /tmp/ao-ed.key -rawin -in /tmp/ao-msg -out /tmp/ao-sig >/dev/null 2>&1")
73+
local sig_hex = io.popen("xxd -p /tmp/ao-sig"):read("*l")
74+
os.setenv("AUTH_SIGNATURE_TYPE", "ed25519")
75+
os.setenv("AUTH_SIGNATURE_PUBLIC", "/tmp/ao-ed.pub")
76+
os.setenv("AUTH_REQUIRE_SIGNATURE", "1")
77+
package.loaded["ao.shared.auth"] = nil
78+
local auth2 = require("ao.shared.auth")
79+
local ok, err = auth2.require_signature({ Action = "PublishVersion", ["Site-Id"] = "site-x", ["Request-Id"] = "rid-x", Signature = sig_hex })
80+
if not ok then error("ed25519 signature should verify: " .. tostring(err)) end
81+
local ok2, err2 = auth2.require_signature({ Action = "PublishVersion", ["Site-Id"] = "site-x", ["Request-Id"] = "rid-x", Signature = "deadbeef" })
82+
if ok2 then error("bad signature should fail") end
83+
os.setenv("AUTH_REQUIRE_SIGNATURE", nil)
84+
os.setenv("AUTH_SIGNATURE_TYPE", nil)
85+
os.setenv("AUTH_SIGNATURE_PUBLIC", nil)
86+
package.loaded["ao.shared.auth"] = nil
87+
end
88+
89+
-- Concurrent publish/version set simulation
90+
do
91+
local siteId = "conc-site"
92+
local site = require("ao.site.process")
93+
site.route(with_req({ Action = "PutDraft", ["Site-Id"] = siteId, ["Page-Id"] = "p1", Content = { title = "T" }, ["Actor-Role"] = "editor" }))
94+
local ok1 = site.route(with_req({ Action = "PublishVersion", ["Site-Id"] = siteId, Version = "v1", ["Actor-Role"] = "publisher" }))
95+
local conflict = site.route(with_req({ Action = "PublishVersion", ["Site-Id"] = siteId, Version = "v2", ExpectedVersion = "old", ["Actor-Role"] = "publisher" }))
96+
if conflict.status ~= "ERROR" then error("Expected VERSION_CONFLICT on second publish") end
97+
end
98+
4999
-- Audit rotation/prune: set tiny rotate and emit many records
50100
do
51101
os.setenv = os.setenv or function() end -- no-op if not available

0 commit comments

Comments
 (0)