mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-20 08:06:35 +03:00
174 lines
5.9 KiB
Python
Executable File
174 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Read-only CryptPad discovery. Standard library only; never executes server JS."""
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
MAX_BYTES = 1048576
|
|
APPS = {
|
|
"pad",
|
|
"code",
|
|
"sheet",
|
|
"doc",
|
|
"presentation",
|
|
"slide",
|
|
"kanban",
|
|
"whiteboard",
|
|
"diagram",
|
|
"form",
|
|
"file",
|
|
"drive",
|
|
}
|
|
|
|
|
|
def origin(value):
|
|
"""Require an origin, never accept a document URL as a probe target."""
|
|
if any(ord(c) < 33 for c in value):
|
|
raise ValueError("Origin must not contain whitespace or controls")
|
|
p = urllib.parse.urlsplit(value)
|
|
if (
|
|
p.scheme not in ("https", "http")
|
|
or not p.hostname
|
|
or p.username is not None
|
|
or p.password
|
|
or p.query
|
|
or p.fragment
|
|
or p.path not in ("", "/")
|
|
):
|
|
raise ValueError("Use a bare HTTPS origin without credentials, path, query or fragment")
|
|
_ = p.port
|
|
if p.scheme == "http" and p.hostname not in ("localhost", "127.0.0.1", "::1"):
|
|
raise ValueError("HTTP is allowed only for loopback development instances")
|
|
return p.scheme + "://" + p.netloc
|
|
|
|
|
|
def parse_config(body):
|
|
# Match only upstream's JSON-in-AMD wrapper. No eval, node, or JS execution.
|
|
match = re.fullmatch(
|
|
r"\s*define\(function\s*\(\)\s*\{\s*;?\s*return\s+(\{.*\})\s*;?\s*\}\);?\s*", body, re.S
|
|
)
|
|
if not match:
|
|
raise ValueError("Unrecognized config wrapper; inspect the deployed version")
|
|
obj = json.loads(match.group(1))
|
|
if not isinstance(obj, dict):
|
|
raise ValueError("Expected a configuration object")
|
|
return obj
|
|
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
return None
|
|
|
|
|
|
def fetch(url, timeout):
|
|
opener = urllib.request.build_opener(NoRedirect())
|
|
request = urllib.request.Request(url, headers={"User-Agent": "cryptpad-skill-probe/1"})
|
|
with opener.open(request, timeout=timeout) as response:
|
|
data = response.read(MAX_BYTES + 1)
|
|
if len(data) > MAX_BYTES:
|
|
raise ValueError("Response exceeds 1 MiB limit")
|
|
return data.decode("utf-8")
|
|
|
|
|
|
def probe(target, timeout, fetcher=fetch):
|
|
base = origin(target)
|
|
result = {"origin": base, "scope": "public HTTP discovery only", "endpoints": {}}
|
|
for path in ("/api/config", "/cryptpad-api.js"):
|
|
try:
|
|
body = fetcher(base + path, timeout)
|
|
if path == "/api/config":
|
|
config = parse_config(body)
|
|
result["config"] = {
|
|
k: config[k]
|
|
for k in (
|
|
"httpUnsafeOrigin",
|
|
"httpSafeOrigin",
|
|
"websocketPath",
|
|
"enableEmbedding",
|
|
"appsToDisable",
|
|
"onlyOffice",
|
|
)
|
|
if k in config
|
|
}
|
|
result["endpoints"][path] = {"status": "recognized"}
|
|
else:
|
|
recognized = "CryptPadAPI" in body and "onSave" in body and "onNewKey" in body
|
|
result["endpoints"][path] = {
|
|
"status": "recognized" if recognized else "unrecognized"
|
|
}
|
|
except urllib.error.HTTPError as exc:
|
|
result["endpoints"][path] = {"status": "http_error", "code": exc.code}
|
|
exc.close()
|
|
except (OSError, ValueError, UnicodeError):
|
|
# Do not echo response bodies, redirect URLs, or arbitrary error text.
|
|
result["endpoints"][path] = {"status": "unavailable_or_unrecognized"}
|
|
result["ok"] = all(v["status"] == "recognized" for v in result["endpoints"].values())
|
|
result["limitations"] = (
|
|
"Does not verify WebSockets, browser CSP/CORS, login, editing, save or server version."
|
|
)
|
|
return result
|
|
|
|
|
|
def inspect_link(value):
|
|
value = value.strip()
|
|
if not value or any(ord(c) < 33 for c in value):
|
|
raise ValueError("Provide one URL without whitespace or controls")
|
|
p = urllib.parse.urlsplit(value)
|
|
base = origin(p.scheme + "://" + p.netloc)
|
|
app = p.path.strip("/").split("/")[0]
|
|
return {
|
|
"origin": base,
|
|
"application": app if app in APPS else "unknown",
|
|
"has_fragment": bool(p.fragment),
|
|
"has_query": bool(p.query),
|
|
"key_validity": "not assessed",
|
|
"access_rights": "not assessed",
|
|
"network_requests": 0,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
epilog="JSON output. Exit 0: success; 1: probe incomplete; 2: invalid input. Link input is stdin to keep secrets out of argv.",
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
p = sub.add_parser("probe", help="GET public config and integration loader; redirects refused")
|
|
p.add_argument("--origin", required=True)
|
|
p.add_argument("--timeout", type=float, default=10)
|
|
sub.add_parser(
|
|
"inspect-link", help="Read one URL from stdin; output no path, key or query values"
|
|
)
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.command == "probe":
|
|
if not 0 < args.timeout <= 60:
|
|
raise ValueError("Timeout must be greater than 0 and at most 60 seconds")
|
|
result = probe(args.origin, args.timeout)
|
|
else:
|
|
value = sys.stdin.read(16385)
|
|
if len(value) > 16384:
|
|
raise ValueError("URL input exceeds 16 KiB")
|
|
result = inspect_link(value)
|
|
print(json.dumps(result, sort_keys=True))
|
|
return 0 if result.get("ok", True) else 1
|
|
except (ValueError, OSError):
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"error": "Invalid input. Use a bare origin for probe or one valid URL on stdin for inspect-link."
|
|
}
|
|
),
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|