Files

126 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Verify-before-save probe for the LinkedIn connector.
Skald runs this after the user fills the form and before the activation is
persisted, so a cookie that was copied wrong is rejected while the form is still
on screen rather than failing later inside a browser session.
Deliberately stdlib-only and browser-free: the connector's Chromium is ~350 MB
and is not downloaded until the first tool call, which no verify timeout would
survive. A plain authenticated GET answers the only question that matters here —
does LinkedIn still accept this cookie.
LinkedIn answers that question with three distinguishable behaviours on /feed/,
measured rather than assumed:
no cookie at all 302 chain ending on /uas/login
cookie rejected 302 from /feed/ to /feed/ — an endless self-redirect, which
is LinkedIn trying and failing to re-establish the session
cookie accepted 200 with the feed
Prints one JSON object on stdout, nothing else. Never echoes the cookie.
"""
from __future__ import annotations
import http.cookiejar
import json
import sys
import urllib.error
import urllib.request
from session import read_config
PROBE_URL = "https://www.linkedin.com/feed/"
# Substrings that mean LinkedIn served a signed-out page instead of the feed.
SIGNED_OUT_MARKERS = ("/login", "/uas/login", "/authwall", "/checkpoint", "signup")
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
def out(ok: bool, message: str, **details: object) -> None:
payload: dict = {"ok": ok, "message": message}
if details:
payload["details"] = details
print(json.dumps(payload))
sys.exit(0 if ok else 1)
def build_opener(li_at: str, jsessionid: str) -> urllib.request.OpenerDirector:
"""An opener carrying the session cookies, behaving like a browser."""
jar = http.cookiejar.CookieJar()
def add(name: str, value: str) -> None:
jar.set_cookie(http.cookiejar.Cookie(
version=0, name=name, value=value, port=None, port_specified=False,
domain=".linkedin.com", domain_specified=True, domain_initial_dot=True,
path="/", path_specified=True, secure=True, expires=None,
discard=False, comment=None, comment_url=None, rest={}, rfc2109=False,
))
add("li_at", li_at)
if jsessionid:
add("JSESSIONID", jsessionid if jsessionid.startswith('"') else f'"{jsessionid}"')
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def main() -> None:
li_at, jsessionid, user_agent = read_config()
if not li_at:
out(False, "No li_at cookie provided.")
# A real li_at is a long opaque token. Catching the obvious paste mistakes
# here gives a far better message than LinkedIn's redirect would.
if li_at.lower().startswith("li_at="):
out(False, "Paste only the cookie value, not the 'li_at=' prefix.")
if len(li_at) < 20:
out(False, f"That li_at value looks too short ({len(li_at)} chars) to be a session cookie.")
request = urllib.request.Request(PROBE_URL, headers={
"User-Agent": user_agent or DEFAULT_UA,
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
})
try:
with build_opener(li_at, jsessionid).open(request, timeout=20) as response:
status = response.status
final_url = response.geturl()
except urllib.error.HTTPError as exc:
# urllib raises here when a redirect chain does not terminate. LinkedIn
# sends /feed/ back to itself for a session it will not accept, so this
# is the invalid-cookie case rather than a transport failure.
if exc.code in (301, 302, 303, 307, 308):
out(False, "LinkedIn rejected this cookie — it is expired, or was "
"invalidated by signing out of the browser it came from.")
if exc.code == 999:
# LinkedIn's anti-automation status. It says nothing about the cookie.
out(False, "LinkedIn refused the probe (HTTP 999) without checking the "
"cookie. Wait a moment and try again.")
out(False, f"LinkedIn returned HTTP {exc.code}.")
except urllib.error.URLError as exc:
out(False, f"Could not reach LinkedIn: {exc.reason}")
except Exception as exc: # noqa: BLE001 - the probe must never crash the form
out(False, f"Probe failed: {exc}")
if any(marker in final_url for marker in SIGNED_OUT_MARKERS):
out(False, "LinkedIn served the sign-in page — the cookie was not accepted.")
if status == 200:
out(
True,
"LinkedIn session cookie is valid.",
user_agent_pinned=bool(user_agent),
jsessionid_provided=bool(jsessionid),
)
out(False, f"LinkedIn returned HTTP {status} instead of the feed.")
if __name__ == "__main__":
main()