{"record":{"id":"6babb8a021bb3fbc","repo":"iflytek/astron-agent","slug":"skill-resource-is-unavailable-security","errorCode":null,"errorMessage":"Skill resource is unavailable","messagePattern":"Skill resource is unavailable","errorType":"exception","errorClass":"RuntimeError","httpStatus":null,"severity":"error","filePath":"core/agent/service/plugin/skill_resource_security.py","lineNumber":145,"sourceCode":"    expiry = int(parameters[\"x-amz-expires\"])\n    if not 1 <= expiry <= 604800:\n        raise ValueError\n\n\ndef validate_skill_resource_url(url: str) -> str:\n    \"\"\"Require the configured origin, console bucket, Skill prefix, and SigV4 shape.\"\"\"\n    candidate_value = str(url or \"\").strip()\n    origin_value = (os.getenv(SKILL_RESOURCE_TRUSTED_ORIGIN_ENV) or \"\").strip()\n    bucket = (os.getenv(SKILL_RESOURCE_TRUSTED_BUCKET_ENV) or \"\").strip()\n    try:\n        _validate_resource_inputs(candidate_value, origin_value, bucket)\n        candidate = urlsplit(candidate_value)\n        origin = urlsplit(origin_value)\n        _validate_resource_origin(candidate, origin)\n        _validate_resource_path(candidate, origin, bucket)\n        _validate_sigv4_parameters(_parse_sigv4_parameters(candidate.query))\n    except (TypeError, ValueError):\n        raise RuntimeError(SKILL_RESOURCE_ERROR) from None\n    return candidate_value\n\n\nasync def read_bounded_response(response: aiohttp.ClientResponse, limit: int) -> bytes:\n    \"\"\"Stream at most ``limit + 1`` bytes so absent Content-Length stays bounded.\"\"\"\n    if limit < 1 or limit > MAX_SKILL_RESOURCE_BYTES:\n        raise RuntimeError(SKILL_RESOURCE_ERROR)\n    content_length = response.content_length\n    if content_length is not None and content_length > limit:\n        raise RuntimeError(SKILL_RESOURCE_ERROR)\n    value = bytearray()\n    async for chunk in response.content.iter_chunked(64 * 1024):\n        remaining = limit + 1 - len(value)\n        if remaining <= 0:\n            break\n        value.extend(chunk[:remaining])\n        if len(value) > limit:\n            raise RuntimeError(SKILL_RESOURCE_ERROR)","sourceCodeStart":127,"sourceCodeEnd":163,"githubUrl":"https://github.com/iflytek/astron-agent/blob/5e758547a83371a5a4b29dadf4ac03e8dd527635/core/agent/service/plugin/skill_resource_security.py#L127-L163","documentation":"validate_skill_resource_url is the trust boundary for server-derived Skill resource URLs. It raises the sentinel RuntimeError('Skill resource is unavailable') whenever the candidate URL fails any check: basic shape (non-empty, <=8192 chars, no CR/LF/TAB), exact-match origin (scheme, host, port from SKILL_RESOURCE_TRUSTED_ORIGIN), path under <origin-path>/<bucket>/skill-files/, or SigV4 presigned query parameters. The generic message deliberately hides which check failed so attackers get no oracle.","triggerScenarios":"A URL is passed whose host/port/scheme differs from SKILL_RESOURCE_TRUSTED_ORIGIN, whose decoded path does not start with <origin-path>/<SKILL_RESOURCE_TRUSTED_BUCKET>/skill-files/ followed by a non-empty key, which contains userinfo, a fragment, invalid percent-escapes, path segments '.'/'..', or whose query lacks/malforms any required SigV4 parameter (x-amz-algorithm!=AWS4-HMAC-SHA256, bad x-amz-date/x-amz-signature format, x-amz-expires outside 1..604800, duplicate or empty query keys). Also raised when the env vars SKILL_RESOURCE_TRUSTED_ORIGIN / SKILL_RESOURCE_TRUSTED_BUCKET are unset/empty/malformed (bucket must match [a-z0-9][a-z0-9.-]{1,61}[a-z0-9]).","commonSituations":"SKILL_RESOURCE_TRUSTED_ORIGIN or SKILL_RESOURCE_TRUSTED_BUCKET not configured in the deployment environment; presigned URLs generated for a different bucket or path prefix than skill-files/; URLs that expired and were re-signed with a different algorithm or truncated query; a stored URL that was URL-decoded/re-encoded losing the signature; redirect targets (e.g. S3 regional endpoints) whose host differs from the trusted origin.","solutions":["Set SKILL_RESOURCE_TRUSTED_ORIGIN and SKILL_RESOURCE_TRUSTED_BUCKET env vars correctly and confirm the URL's scheme/host/effective port match the origin exactly.","Ensure the URL path decodes to <origin-path>/<bucket>/skill-files/<object-key> with no '..' segments or control characters.","Regenerate the SigV4 presigned URL so the query contains all six x-amz-* parameters with valid formats (AWS4-HMAC-SHA256, ISO8601 date, 64-hex signature, expires 1..604800).","Compare the URL against the origin with a script (scheme, hostname, effective port) to find the mismatched component before calling the API."],"exampleFix":"// before (origin mismatch: URL for bucket B against configured bucket A)\ndownload_skill_resource(session, \"https://cdn.example.com/other-bucket/skill-files/x?...\", limit)\n// after (env: SKILL_RESOURCE_TRUSTED_BUCKET=other-bucket or a URL for the configured bucket)\nos.environ[\"SKILL_RESOURCE_TRUSTED_BUCKET\"] = \"other-bucket\"\ndownload_skill_resource(session, \"https://cdn.example.com/other-bucket/skill-files/x?...\", limit)","handlingStrategy":"validation","validationCode":"from urllib.parse import urlsplit\nimport os, re\ndef can_pass_skill_url_validation(url: str) -> bool:\n    origin_value = (os.getenv(\"SKILL_RESOURCE_TRUSTED_ORIGIN\") or \"\").strip()\n    bucket = (os.getenv(\"SKILL_RESOURCE_TRUSTED_BUCKET\") or \"\").strip()\n    c, o = urlsplit(url.strip()), urlsplit(origin_value)\n    port = lambda p: p.port or (443 if p.scheme == \"https\" else 80)\n    path = c.path\n    return bool(\n        url and len(url) <= 8192 and not any(ch in url for ch in \"\\r\\n\\t\")\n        and re.fullmatch(r\"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\", bucket)\n        and c.scheme == o.scheme and c.hostname == o.hostname\n        and port(c) == port(o) and c.username is None and c.password is None\n        and not c.fragment\n        and path.startswith(f\"{o.path.rstrip('/')}/{bucket}/skill-files/\")\n        and len(path) > len(f\"{o.path.rstrip('/')}/{bucket}/skill-files/\")\n        and all(k in c.query for k in (\"X-Amz-Algorithm\", \"X-Amz-Credential\", \"X-Amz-Date\", \"X-Amz-Expires\", \"X-Amz-SignedHeaders\", \"X-Amz-Signature\"))\n    )","typeGuard":"def is_valid_skill_resource_url(url: object) -> bool:\n    return isinstance(url, str) and bool(url.strip()) and can_pass_skill_url_validation(url)","tryCatchPattern":"try:\n    data = await download_skill_resource(session, url, limit)\nexcept RuntimeError as exc:\n    if str(exc) == \"Skill resource is unavailable\":\n        logger.warning(\"skill resource rejected by URL validation\", extra={\"host\": urlsplit(url).hostname})\n        raise SkillResourceUnavailable(url) from exc\n    raise","preventionTips":["Always mint presigned URLs against the exact SKILL_RESOURCE_TRUSTED_ORIGIN and bucket path prefix skill-files/.","Validate env vars SKILL_RESOURCE_TRUSTED_ORIGIN/SKILL_RESOURCE_TRUSTED_BUCKET at service startup, fail fast if empty.","Keep presign TTL short and download immediately after issuing the URL.","Never re-encode/decode the signed URL (percent-decoding breaks the signature and path checks)."],"tags":["security","validation","url-validation","ssrf-protection"],"backgroundTag":"invalid-url","analyzedSha":"5e758547a83371a5a4b29dadf4ac03e8dd527635","analyzedAt":"2026-09-12T08:03:51.356Z","contentChangedAt":"2026-09-12T08:03:51.356Z","schemaVersion":2},"datasetVersion":"2026-09-15T23:17:13.987Z"}