From 3a651c31a6bf4eb99f621cc833db900d88440753 Mon Sep 17 00:00:00 2001 From: mickmicksh Date: Thu, 30 Jul 2026 09:02:55 +0200 Subject: [PATCH 1/2] Harden CLI and CI security --- .github/dependabot.yml | 22 +++++++ .github/workflows/ci.yml | 11 ++-- .github/workflows/issue-automation.yml | 49 +++++++++------ .github/workflows/publish-npm.yml | 7 ++- .github/workflows/publish-pypi.yml | 7 ++- SECURITY.md | 4 +- lap/cli/auth.py | 52 +++++++++++++++- lap/cli/main.py | 18 +++--- sdks/typescript/package-lock.json | 22 +++++-- sdks/typescript/package.json | 2 +- sdks/typescript/src/auth.ts | 86 +++++++++++++++++++++++--- sdks/typescript/src/cli.ts | 9 +-- sdks/typescript/tests/auth.test.ts | 60 ++++++++++++++++++ sdks/typescript/tests/skill.test.ts | 27 ++++++++ tests/test_cli_auth.py | 32 ++++++++++ tests/test_skill_update.py | 17 +++++ 16 files changed, 361 insertions(+), 64 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0162948 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: /sdks/typescript + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b5333c..ead962c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: python-tests: name: Python Tests (${{ matrix.python-version }}) @@ -15,10 +18,10 @@ jobs: python-version: ["3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -46,10 +49,10 @@ jobs: node-version: ["18", "20"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/issue-automation.yml b/.github/workflows/issue-automation.yml index cff1d21..2c37261 100644 --- a/.github/workflows/issue-automation.yml +++ b/.github/workflows/issue-automation.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Validate spec request - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const body = context.payload.issue.body || ''; @@ -29,28 +29,39 @@ jobs: return; } + let parsed; try { - const resp = await fetch(specUrl, { method: 'HEAD', signal: AbortSignal.timeout(10000) }); - if (resp.ok) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `Spec URL is reachable (HTTP ${resp.status}). Thanks for including the direct link!` - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `Spec URL returned HTTP ${resp.status}. Please double-check the link.` - }); - } - } catch (e) { + parsed = new URL(specUrl); + } catch { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: 'Could not reach the spec URL. Please verify the link is publicly accessible.' + body: 'The spec URL is not a valid absolute URL. Please provide a direct public HTTPS link.' }); + return; + } + + if ( + parsed.protocol !== 'https:' || + parsed.username || + parsed.password || + !parsed.hostname + ) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'The spec URL must be a direct public HTTPS link without embedded credentials.' + }); + return; } + + // Do not fetch reporter-controlled URLs from a privileged Actions + // runner. Maintainers can inspect the URL during normal triage. + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: 'The spec URL has a valid HTTPS format. Thanks for including the direct link!' + }); diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index cd96233..e425e43 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -4,6 +4,9 @@ on: release: types: [published] +permissions: + contents: read + jobs: publish: runs-on: ubuntu-latest @@ -12,9 +15,9 @@ jobs: run: working-directory: sdks/typescript steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 74ce424..3d40d05 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -9,11 +9,12 @@ jobs: runs-on: ubuntu-latest environment: pypi permissions: + contents: read id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" @@ -24,4 +25,4 @@ jobs: run: python -m build - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/SECURITY.md b/SECURITY.md index 72391f1..d13dfff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | |---------|-----------| -| 0.6.x | Yes | -| < 0.6 | No | +| 0.7.x | Yes | +| < 0.7 | No | Only the latest release receives security fixes. We recommend always running the most recent version. diff --git a/lap/cli/auth.py b/lap/cli/auth.py index f5a2b02..ba860b0 100644 --- a/lap/cli/auth.py +++ b/lap/cli/auth.py @@ -6,9 +6,11 @@ """ import json +import ipaddress import os import stat import sys +import urllib.parse import urllib.request import urllib.error import webbrowser @@ -24,9 +26,55 @@ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json" +def _validate_web_url(value, *, label, allow_query=True): + """Validate a web URL, allowing plaintext HTTP only for loopback hosts.""" + if not isinstance(value, str) or not value or value != value.strip(): + raise ValueError(f"{label} must be a valid absolute URL") + if "\\" in value or any(ord(char) < 0x20 or ord(char) == 0x7f for char in value): + raise ValueError(f"{label} must be a valid absolute URL") + + try: + parsed = urllib.parse.urlsplit(value) + hostname = parsed.hostname + # Accessing port forces urllib to reject malformed/out-of-range ports. + parsed.port + except ValueError as exc: + raise ValueError(f"{label} must be a valid absolute URL") from exc + + if not hostname: + raise ValueError(f"{label} must be a valid absolute URL") + if parsed.username is not None or parsed.password is not None: + raise ValueError(f"{label} must not contain credentials") + if not allow_query and (parsed.query or parsed.fragment): + raise ValueError(f"{label} must not contain a query string or fragment") + + is_loopback = hostname.lower() == "localhost" + if not is_loopback: + try: + is_loopback = ipaddress.ip_address(hostname).is_loopback + except ValueError: + pass + + if parsed.scheme == "https": + return value + if parsed.scheme == "http" and is_loopback: + return value + raise ValueError(f"{label} must use HTTPS (HTTP is allowed only for loopback development)") + + +def validate_registry_url(value): + """Validate a registry base URL and normalize trailing slashes.""" + return _validate_web_url(value, label="Registry URL", allow_query=False).rstrip("/") + + +def validate_auth_url(value): + """Validate a browser authentication URL returned by the registry.""" + return _validate_web_url(value, label="Authentication URL") + + def get_registry_url(): - """Get registry URL from env or default.""" - return os.environ.get("LAP_REGISTRY", DEFAULT_REGISTRY).rstrip("/") + """Get and validate the registry URL from the environment or default.""" + return validate_registry_url(os.environ.get("LAP_REGISTRY", DEFAULT_REGISTRY)) # ── Credentials ───────────────────────────────────────────────────── diff --git a/lap/cli/main.py b/lap/cli/main.py index 3a807fd..9a49e26 100644 --- a/lap/cli/main.py +++ b/lap/cli/main.py @@ -391,7 +391,7 @@ def cmd_login(args): """Authenticate with the LAP registry via GitHub OAuth.""" from lap.cli.auth import ( api_request, save_credentials, load_credentials, - poll_sse_stream, get_registry_url, + poll_sse_stream, get_registry_url, validate_auth_url, ) import webbrowser @@ -409,7 +409,10 @@ def cmd_login(args): result = api_request("POST", "/auth/cli/session", body=body if body else None) session_id = result["session_id"] stream_key = result["stream_key"] - auth_url = result["auth_url"] + try: + auth_url = validate_auth_url(result["auth_url"]) + except (KeyError, ValueError) as exc: + error(f"Registry returned an invalid authentication URL: {exc}") # Open browser print(f"Opening browser for GitHub authorization...") @@ -794,14 +797,9 @@ def _is_valid_skill_name(name: str) -> bool: def _validate_registry_url(url: str) -> str: """Ensure registry URL uses HTTPS (except localhost for dev).""" - for prefix in ("http://localhost:", "http://localhost/", "http://127.0.0.1:", "http://127.0.0.1/"): - if url.startswith(prefix): - return url - if url in ("http://localhost", "http://127.0.0.1"): - return url - if not url.startswith("https://"): - raise ValueError(f"Registry URL must use HTTPS: {url}") - return url + from lap.cli.auth import validate_registry_url + + return validate_registry_url(url) def _register_session_hook(target: str) -> None: diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 0ff4d3e..2a527f3 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,15 +1,15 @@ { "name": "@lap-platform/lapsh", - "version": "0.3.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lap-platform/lapsh", - "version": "0.3.0", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.3.0" }, "bin": { "lapsh": "dist/src/cli.js" @@ -55,9 +55,19 @@ "license": "Python-2.0" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index ee4053a..cb17777 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -51,7 +51,7 @@ "typescript": "^5.9.3" }, "dependencies": { - "js-yaml": "^4.1.1" + "js-yaml": "^4.3.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.20.0" diff --git a/sdks/typescript/src/auth.ts b/sdks/typescript/src/auth.ts index 9eecf57..80c3862 100644 --- a/sdks/typescript/src/auth.ts +++ b/sdks/typescript/src/auth.ts @@ -10,14 +10,63 @@ import * as path from 'path'; import * as os from 'os'; import * as http from 'http'; import * as https from 'https'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; const DEFAULT_REGISTRY = 'https://registry.lap.sh'; const CREDENTIALS_DIR = path.join(os.homedir(), '.lap'); const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json'); export function getRegistryUrl(): string { - return (process.env.LAP_REGISTRY || DEFAULT_REGISTRY).replace(/\/$/, ''); + return validateRegistryUrl(process.env.LAP_REGISTRY || DEFAULT_REGISTRY); +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (normalized === 'localhost' || normalized === '::1') return true; + + const octets = normalized.split('.'); + return ( + octets.length === 4 && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) && + Number(octets[0]) === 127 + ); +} + +function parseTrustedWebUrl(value: unknown, label: string): URL { + if ( + typeof value !== 'string' || + value.length === 0 || + value.trim() !== value || + value.includes('\\') || + /[\u0000-\u001f\u007f]/.test(value) + ) { + throw new Error(`${label} must be a valid absolute URL.`); + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error(`${label} must be a valid absolute URL.`); + } + + if (!parsed.hostname) { + throw new Error(`${label} must be a valid absolute URL.`); + } + if (parsed.username || parsed.password) { + throw new Error(`${label} must not contain credentials.`); + } + if (parsed.protocol === 'https:') return parsed; + if (parsed.protocol === 'http:' && isLoopbackHostname(parsed.hostname)) return parsed; + throw new Error(`${label} must use HTTPS (HTTP is allowed only for loopback development).`); +} + +export function validateRegistryUrl(value: string): string { + const parsed = parseTrustedWebUrl(value, 'Registry URL'); + if (parsed.search || parsed.hash) { + throw new Error('Registry URL must not contain a query string or fragment.'); + } + return value.replace(/\/+$/, ''); } // ── Credentials ───────────────────────────────────────────────────── @@ -174,10 +223,31 @@ export function pollSseStream( // ── Browser open ──────────────────────────────────────────────────── -export function openBrowser(url: string): void { - const cmd = - process.platform === 'win32' ? `start "" "${url}"` : - process.platform === 'darwin' ? `open "${url}"` : - `xdg-open "${url}"`; - exec(cmd); +export interface BrowserLaunchCommand { + command: string; + args: string[]; +} + +export function validateAuthUrl(value: unknown): string { + return parseTrustedWebUrl(value, 'Authentication URL').href; +} + +export function getBrowserLaunchCommand( + url: unknown, + platform: NodeJS.Platform = process.platform +): BrowserLaunchCommand { + const authUrl = validateAuthUrl(url); + + if (platform === 'win32') { + return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', authUrl] }; + } + if (platform === 'darwin') { + return { command: 'open', args: [authUrl] }; + } + return { command: 'xdg-open', args: [authUrl] }; +} + +export function openBrowser(url: unknown): void { + const { command, args } = getBrowserLaunchCommand(url); + execFile(command, args, { shell: false }); } diff --git a/sdks/typescript/src/cli.ts b/sdks/typescript/src/cli.ts index 55f031d..dcaab1b 100644 --- a/sdks/typescript/src/cli.ts +++ b/sdks/typescript/src/cli.ts @@ -34,6 +34,7 @@ import { pollSseStream, openBrowser, getRegistryUrl, + validateRegistryUrl as validateRegistryUrlFromAuth, } from './auth'; import { parse } from './parser'; import { toLap } from './serializer'; @@ -211,13 +212,7 @@ export function isValidSkillName(name: string): boolean { } export function validateRegistryUrl(url: string): string { - const localPrefixes = ['http://localhost:', 'http://localhost/', 'http://127.0.0.1:', 'http://127.0.0.1/']; - for (const prefix of localPrefixes) { - if (url.startsWith(prefix)) return url; - } - if (url === 'http://localhost' || url === 'http://127.0.0.1') return url; - if (!url.startsWith('https://')) throw new Error(`Registry URL must use HTTPS: ${url}`); - return url; + return validateRegistryUrlFromAuth(url); } // ── Auth Commands ─────────────────────────────────────────────────── diff --git a/sdks/typescript/tests/auth.test.ts b/sdks/typescript/tests/auth.test.ts index 0f67b5a..5f2bd8e 100644 --- a/sdks/typescript/tests/auth.test.ts +++ b/sdks/typescript/tests/auth.test.ts @@ -10,7 +10,10 @@ import { clearCredentials, getToken, getRegistryUrl, + validateRegistryUrl, apiRequest, + getBrowserLaunchCommand, + validateAuthUrl, } from '../src/auth'; // ── helpers ───────────────────────────────────────────────────────────────── @@ -147,6 +150,63 @@ describe('Registry URL', () => { const url = getRegistryUrl(); assert.ok(!url.endsWith('/'), `Registry URL should not end with slash, got: ${url}`); }); + + it('allows HTTP only for real loopback hosts', () => { + assert.strictEqual(validateRegistryUrl('http://127.0.0.2:8787'), 'http://127.0.0.2:8787'); + assert.strictEqual(validateRegistryUrl('http://[::1]:8787'), 'http://[::1]:8787'); + assert.throws(() => validateRegistryUrl('http://registry.lap.sh'), /must use HTTPS/); + assert.throws(() => validateRegistryUrl('http://localhost:8787@evil.example'), /credentials/); + assert.throws(() => validateRegistryUrl('http://127.0.0.1@evil.example'), /credentials/); + }); + + it('rejects credentials, query strings, fragments, and malformed registry URLs', () => { + assert.throws(() => validateRegistryUrl('https://user:pass@registry.lap.sh'), /credentials/); + assert.throws(() => validateRegistryUrl('https://registry.lap.sh?tenant=other'), /query string/); + assert.throws(() => validateRegistryUrl('https://registry.lap.sh/#other'), /query string/); + assert.throws(() => validateRegistryUrl('not a URL'), /valid absolute URL/); + }); +}); + +// ── Browser open ───────────────────────────────────────────────────────────── + +describe('Browser open', () => { + it('passes an untrusted authentication URL as one argument without a shell', () => { + const maliciousUrl = 'https://example.com/"; touch /tmp/pwned; #'; + const launch = getBrowserLaunchCommand(maliciousUrl, 'linux'); + + assert.strictEqual(launch.command, 'xdg-open'); + assert.deepStrictEqual(launch.args, [new URL(maliciousUrl).href]); + }); + + it('uses shell-free browser executables on every supported platform', () => { + const authUrl = 'https://example.com/authorize?state=a&next=b'; + + assert.deepStrictEqual(getBrowserLaunchCommand(authUrl, 'linux'), { + command: 'xdg-open', + args: [authUrl], + }); + assert.deepStrictEqual(getBrowserLaunchCommand(authUrl, 'darwin'), { + command: 'open', + args: [authUrl], + }); + assert.deepStrictEqual(getBrowserLaunchCommand(authUrl, 'win32'), { + command: 'rundll32.exe', + args: ['url.dll,FileProtocolHandler', authUrl], + }); + }); + + it('rejects malformed or non-web authentication URLs', () => { + assert.throws(() => validateAuthUrl(undefined), /valid absolute URL/); + assert.throws(() => validateAuthUrl('not a URL'), /valid absolute URL/); + assert.throws(() => validateAuthUrl('file:///tmp/pwned'), /valid absolute URL/); + assert.throws(() => validateAuthUrl('javascript:alert(1)'), /valid absolute URL/); + assert.throws(() => validateAuthUrl('http://evil.example/authorize'), /must use HTTPS/); + assert.throws(() => validateAuthUrl('https://user:pass@example.com/authorize'), /credentials/); + assert.strictEqual( + validateAuthUrl('http://[::1]:8787/authorize'), + 'http://[::1]:8787/authorize', + ); + }); }); // ── apiRequest ─────────────────────────────────────────────────────────────── diff --git a/sdks/typescript/tests/skill.test.ts b/sdks/typescript/tests/skill.test.ts index b73d53f..3dd25fc 100644 --- a/sdks/typescript/tests/skill.test.ts +++ b/sdks/typescript/tests/skill.test.ts @@ -860,6 +860,33 @@ describe('Metadata helpers', () => { () => validateRegistryUrl('http://localhost-attacker.com'), /must use HTTPS/, ); + assert.throws( + () => validateRegistryUrl('http://localhost:8080@evil.example'), + /credentials/, + ); + assert.throws( + () => validateRegistryUrl('http://127.0.0.1@evil.example'), + /credentials/, + ); + }); + + it('allows IPv6 loopback and rejects ambiguous base URLs', () => { + assert.strictEqual( + validateRegistryUrl('http://[::1]:8787'), + 'http://[::1]:8787', + ); + assert.throws( + () => validateRegistryUrl('https://user:pass@registry.lap.sh'), + /credentials/, + ); + assert.throws( + () => validateRegistryUrl('https://registry.lap.sh?tenant=other'), + /query string/, + ); + assert.throws( + () => validateRegistryUrl('not a URL'), + /valid absolute URL/, + ); }); }); }); diff --git a/tests/test_cli_auth.py b/tests/test_cli_auth.py index dad2c11..430878a 100644 --- a/tests/test_cli_auth.py +++ b/tests/test_cli_auth.py @@ -109,6 +109,38 @@ def test_env_override(self): assert url == "http://localhost:8787" assert not url.endswith("/") # Trailing slash stripped + @pytest.mark.parametrize("host", ["localhost", "127.0.0.2", "[::1]"]) + def test_loopback_http_is_allowed(self, host): + assert auth.validate_registry_url(f"http://{host}:8787") == f"http://{host}:8787" + + @pytest.mark.parametrize( + "url", + [ + "http://registry.lap.sh", + "http://localhost:8787@evil.example", + "http://127.0.0.1@evil.example", + "ftp://registry.lap.sh", + "https://user:pass@registry.lap.sh", + "https://registry.lap.sh?override=1", + "not a URL", + ], + ) + def test_untrusted_registry_urls_are_rejected(self, url): + with pytest.raises(ValueError): + auth.validate_registry_url(url) + + def test_auth_url_requires_https_or_loopback(self): + assert auth.validate_auth_url("https://github.com/login/oauth") == ( + "https://github.com/login/oauth" + ) + assert auth.validate_auth_url("http://[::1]:8787/auth") == ( + "http://[::1]:8787/auth" + ) + with pytest.raises(ValueError, match="HTTPS"): + auth.validate_auth_url("http://evil.example/auth") + with pytest.raises(ValueError): + auth.validate_auth_url("javascript:alert(1)") + # ── SSE parsing ────────────────────────────────────────────────────── diff --git a/tests/test_skill_update.py b/tests/test_skill_update.py index 7bcb854..d8eb62a 100644 --- a/tests/test_skill_update.py +++ b/tests/test_skill_update.py @@ -463,6 +463,23 @@ def test_validate_registry_url_rejects_localhost_prefix_confusion(): _validate_registry_url("http://localhost.evil.com") with pytest.raises(ValueError, match="HTTPS"): _validate_registry_url("http://localhost-attacker.com") + with pytest.raises(ValueError, match="credentials"): + _validate_registry_url("http://localhost:8787@evil.example") + with pytest.raises(ValueError, match="credentials"): + _validate_registry_url("http://127.0.0.1@evil.example") + + +def test_validate_registry_url_allows_ipv6_loopback(): + assert _validate_registry_url("http://[::1]:8787") == "http://[::1]:8787" + + +def test_validate_registry_url_rejects_credentials_and_malformed_values(): + with pytest.raises(ValueError, match="credentials"): + _validate_registry_url("https://user:pass@registry.lap.sh") + with pytest.raises(ValueError, match="query string"): + _validate_registry_url("https://registry.lap.sh?tenant=other") + with pytest.raises(ValueError, match="valid absolute URL"): + _validate_registry_url("not a URL") # ── C7: skill-install writes metadata ──────────────────────────────── From 188a56d1f9d7a1eba010197666ce55d04b26f552 Mon Sep 17 00:00:00 2001 From: mickmicksh Date: Thu, 30 Jul 2026 16:48:55 +0200 Subject: [PATCH 2/2] Prepare 0.7.1 security release --- CHANGELOG.md | 10 ++++++++++ lap/__init__.py | 2 +- pyproject.toml | 2 +- sdks/typescript/package-lock.json | 4 ++-- sdks/typescript/package.json | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a3b7e..7b2b79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to LAP (Lean API Platform) will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.1] - 2026-07-30 + +### Security +- **Registry-controlled command injection** -- launch login URLs with shell-free process APIs and validate server-provided authentication URLs before opening them +- **Registry URL validation** -- require HTTPS except for real loopback development hosts and reject credentials, malformed URLs, and prefix-bypass payloads +- **Issue automation SSRF** -- stop privileged GitHub runners from fetching reporter-controlled URLs + +### Changed +- **Dependency hardening** -- update `js-yaml` and pin GitHub Actions to reviewed commit SHAs + ## [0.7.0] - 2026-03-26 ### Added diff --git a/lap/__init__.py b/lap/__init__.py index ed6dbc3..4c11769 100644 --- a/lap/__init__.py +++ b/lap/__init__.py @@ -1,3 +1,3 @@ """LAP -- Lean API Platform. Token-efficient API specs for AI agents.""" -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/pyproject.toml b/pyproject.toml index 96195b3..0f7eb5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "lapsh" -version = "0.7.0" +version = "0.7.1" description = "Lean API Platform -- Token-efficient API specs for AI agents" readme = "README.md" license = "Apache-2.0" diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index 2a527f3..9d12d45 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lap-platform/lapsh", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lap-platform/lapsh", - "version": "0.7.0", + "version": "0.7.1", "license": "Apache-2.0", "dependencies": { "js-yaml": "^4.3.0" diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index cb17777..51527fa 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@lap-platform/lapsh", - "version": "0.7.0", + "version": "0.7.1", "description": "TypeScript SDK for LAP (Lean API Platform) -- Parse and work with LAP API specifications", "main": "dist/src/index.js", "types": "dist/src/index.d.ts",