- [high] Hardcoded
po_token/visitor_datainserver/src/common/innertube/innertube.ts. Session-bound values baked into source, shared by every self-hosted instance — they expire for everyone at once and present as a YouTube outage. At minimum log loudly when falling back to the built-in values. - [high] No rate limiting anywhere.
@nestjs/throttleris absent; the proxies are an unauthenticated bandwidth relay through the operator's SOCKS proxy. Also the only defence against id spam — a non-existent video id costs a full innertube round trip (videos.service.ts→client.getInfo), and innertube calls are what attract blocks. Caching can't cover random ids.- Whatever guard goes in must exempt the in-process SSR path:
useVtFetchreaches the api viaglobal.nestApp.inject()(client/app/composables/vtFetch.ts) and arrives carryinglight-my-request's default127.0.0.1, so every server-rendered page would share one bucket and 429 itself under moderate traffic. Theauthority: 'nuxtApp'already set on those calls is the hook to key the exemption off.
- Whatever guard goes in must exempt the in-process SSR path:
- [high] No video id validation before hitting YouTube.
/api/videos/zzspends a full innertube round trip learning that a two-character id can't exist. Playlists already reject locally (ytpl.validateID,playlists.service.ts:18) andisChannelId(core/channels/channel-identifier.ts:14) exists for channels. An 11-character charset check on the video route costs nothing and removes the cheapest id spam. - [high] Failures are never cached, so every bad id is a fresh upstream call.
CacheInterceptorstores only on the success path (next.handle().pipe(tap(...)));tap's next callback doesn't fire for thrown exceptions, so the@CacheTTLs on every core controller do nothing for a 404.ApiExceptionFilteranswers errorsno-store, so a shared cache in front of the instance no longer absorbs repeats either.- Worth a short negative cache: 30-60s on 404 only (well under the 5 min success TTL so a premiere going live or an unlisted video turning public isn't stuck), ~10s max on 502.
- This is an optimisation for accidental amplification (deleted video still linked from a popular page, a crawler), not a defence — the key space is attacker-controlled and filling redis with it is free; the defence is rate limiting above.
CacheInterceptorcan't be reused: needs something that reads on the way in and writes on the way out, so an interceptor withcatchErrorrather than the exception filter (which only sees the way out).
- [high]
ytplno longer parses YouTube, so every playlist is a 502. Confirmed outside the app:node -e "require('ytpl')('UUuAXFkgsw1L7xaCfnd5JJOw')"throwsTypeError: Cannot read properties of undefined (reading 'contents')fromytpl/lib/main.jsfor a playlist that exists. Was invisible before the error taxonomy landed (reported as 500 with empty body; now 502 with a logged reason). Library is unmaintained —core/playlistswants moving to youtubei.js likechannelswas, which would also give it thehas_*probes the taxonomy prefers over catching. - [high] Playlist author links point at the playlist, not the channel.
client/app/components/list/PlaylistEntry.vue:113picks its link by shape:typeof playlist.author === 'string'gives/channel/{authorId}, anything else falls through to/channel/{playlist.id}.VTPlaylistDto.authoris now aVTAuthorDto, so the second branch always fires and every playlist on the channel home tab links to/channel/PLxxxxxxxx— a hard 404. Fix isplaylist.author.id.- Playlists tab is unaffected (passes
hide-author), which is whytests/cypress/e2e/3-pages/channel.cy.tsdoesn't catch it — worth a home-shelf assertion once fixed.
- Playlists tab is unaffected (passes
- [high]
/channel/<id>/channelsis a dead url.'channels'was dropped frompageNames(client/app/composables/channelPages.ts:3) and itsswiper-slideremoved, but nothing redirects. An existing link leavescurrentPage = 'channels'andcurrentPageIndex = -1, handed to swiper as:initial-slide="-1"— channel header renders over an empty body. Fall back to'home'when the param isn't inpageNames. - [high] Community reposts are dropped.
channels.service.ts:528collects onlyYTNodes.BackstagePost, andMemo.getTypematches on the exactstatic typestring (youtubei.jsparser/helpers.js). BothPost(subclassesBackstagePost) andSharedPostcarry different type names, so reposts never reach the community tab — and if YouTube switches the tab topostRendererwholesale the tab goes silently empty rather than erroring. Pass all three constructors tocollectFeedNodes. - [high] Legacy about branch yields
Textnodes, not strings.getAboutMetadatareturnsChannelAboutFullMetadatauntouched for channels YouTube still serves the old way. Field names line up withAboutChannelViewbut types don't:description,country,view_countareTextinstances there, plain strings on the new view.toVTChannelAboutDtoputs aTextobject intodescriptionandlocation,sanitizeHtmlString(server/src/common/sanitize-html.ts) stringifies it to"[object Object]", and the client renders that as the location.view_countsurvives only becauseparseShortenedNumbercalls.toString(). Normalise with.toString()on that branch. Not reachable through e2e fixtures — every channel they use is on the new about view. - [low]
server/src/common/proxy-allowlist.ts:12is missing a semicolon afterimageHostSuffixes. ASI covers it so nothing is broken, butpnpm formatwill rewrite the line the moment anyone runs it. - [low]
proxyStreamtrusts theoriginUrlquery parameter and reflects it into rewritten.m3u8bodies (server/src/core/proxy/proxy.service.ts). Only affects the caller's own response, so not a live vulnerability, but the origin should be derived server-side rather than taken from the client. - [high] Members-only videos not handled on the watch page.
getInfoon one returnsplayability_status.status: "UNPLAYABLE"with YouTube's upsell as the reason ("This video is available to this channel's members on level: … Join this channel to get access to members-only content…").extractAvailability(server/src/mapper/converter/video-info/vt-video-info.extractors.ts) passes that string through untouched, andwatch.vue:80shows it as a generic error toast — viewer gets a call to action ViewTube can't fulfil, with no indication the video is members-only rather than broken. Already special-cases the age-restriction reason a few lines above; members-only wants the same treatment.- Channel listing side handled separately: lockups carry
metadata.metadata_rows[].badges[]of{ text: "Members only", style: "BADGE_MEMBERS_ONLY" }— same signal the watch page could surface on an entry before the click.
- Channel listing side handled separately: lockups carry
- [high]
...autocomplete?q=requests should not be sent. - [low] Turn
strictNullChecksback on. Off in bothserver/tsconfig.jsonandclient/tsconfig.json. Set in🏗️ Switch to a yarn 2 monorepo (#988), 2021-10-15 — collateral from a build migration, never a typing decision. Pays off most in the extractor layer:video.id || video.videoId || video.videoIDis exactly what the flag is for. Long slog;mapper/alone would capture most of the value. - [low] Drop
| anyfrom converter signatures, e.g.toVTVideoDto = (video: VideoSourceApproximation | any). The union collapses toanyand disables checking at precisely the boundary the ~2400 lines of*-source-approximation.tswere written to protect. - [low]
metadata.tschurns nondeterministically. Everynest buildreorders string-literal union members (["none","skip"]<->["skip","none"]), so this tracked generated file produces spurious diffs on every build. Either sort it post-generation or stop tracking it and generate in CI. - [wontfix] Vendored
yt-channel-info(2149 lines underserver/src/core/channels/yt-channel-info/) is unowned code inside the tree, outside themapper/conventions and outsideknip's reach. Probably the right pragmatic call given upstream's state — just know it's there. - [low] Largest files, where bugs will be:
client/app/utils/webVTTParser.ts(782, hand-rolled),client/app/pages/watch.vue(768),client/app/components/list/VideoEntry.vue(653). - [low]
CHANGELOG.mdhas lapsed — nothing since 0.17.0 (#2940) whilepackage.jsonis 0.17.1, and it still feeds the release job (moisout/changelog-create-release). Either resume it or decide it's retired. - [scale] Remove the hardcoded
po_token/visitor_datafromserver/src/common/innertube/innertube.ts. Leftovers from testing the env-var support added for a self-hoster, not intended defaults — but they're the values every instance uses unless overridden, so they ship as a shared fingerprint. Delete them and warn loudly at startup when none are configured.- Open question that gates everything else: does ViewTube still function with no
po_tokenat all? The answer decides whether a public instance needs a token-generating sidecar or just documentation. - Only relevant to the public instance; doesn't bite a self-hoster.
- Open question that gates everything else: does ViewTube still function with no
- [scale] Record the deployment topology. The public instance ran the container inside gluetun for VPN
egress, no proxy. Nothing in the repo says so — not the compose files, not the README, not
env.validation.ts. That knowledge existed only in the operator's head, which makes picking the project back up after a pause needlessly expensive. Adocker-compose.public.ymlor a README section would fix it permanently. - [scale]
VIEWTUBE_PROXY_URLis undocumented and unvalidated. Read straight fromprocess.envinproxyAgent.ts, absent fromenv.validation.tsand from every doc. Add it to the Joi schema if the proxy path is revived. - [scale]
useProxyis a boolean where an egress policy belongs. All ten call sites passuseProxy: true—innertubeFetch, autocomplete, the subscription poller, thumbnails andvideoplayback— against a single global proxy URL. These workloads have opposite requirements: streams and thumbnails are bulk bandwidth and latency-sensitive, innertube calls and the poller are what actually attract blocks. Reviving a rotating proxy means splitting this into a per-call-site policy (direct/rotating) rather than one dial.- Inert on the current deployment — with
VIEWTUBE_PROXY_URLunset,proxyEnabled()is false and everyuseProxy: trueis a no-op, so all egress goes out through whatever the container's network provides. Only relevant if a rotating proxy is reintroduced.
- Inert on the current deployment — with
- [scale] Agent pool constant mismatch in
server/src/common/proxyAgent.ts:proxyAgentUses = 100bounds cleanup, but reuse requiresusages < 10. Once every pooled agent for a URI passes 10 uses, a freshProxyAgentis allocated per request and none are closed until one reaches 100. Invisible at low traffic, continuous churn at scale. Given the commit was✨ Store proxyAgents temporarily (#2901), the 10-vs-100 gap looks unintentional.- Inert on the current deployment (see above).
- [wontfix] In-process SSR via
global.nestApp.inject(). Skips a loopback round trip; the cost is that the client can't be deployed separately from the server. Correct for a single-container product, but a one-way door. - [wontfix] Admin settings pushed into
process.env.NUXT_PUBLIC_*at boot. Toggling registration needs a restart. Fine, just not obvious. - [wontfix]
core/proxyand the tsconfig strictness flags are pre-2023 code. Read as legacy to be corrected, not as patterns to imitate elsewhere.