CVE-2024-28195 (TypeScript, published 2024-03-13T18:15Z)
Description: value: your_spotify is an open source, self hosted Spotify tracking dashboard. YourSpotify versions < 1.9.0 do not protect the API and login flow against Cross-Site Request Forgery (CSRF). Attackers can use this to execute CSRF attacks on victims, allowing them to retrieve, modify or delete data on the affected YourSpotify instance. Using repeated CSRF attacks, it is also possible to create a new user on the victim instance and promote the new user to instance administrator if a legitimate administrator visits a website prepared by an attacker. Note: Real-world exploitability of this vulnerability depends on the browser version and browser settings in use by the victim. This issue has been addressed in version 1.9.0. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/Yooooomi/your_spotify
Files changed:
README.md
apps/client/public/index.html
apps/client/scripts/run/serve.json
apps/client/scripts/run/serve.sh
apps/client/scripts/run/variables.sh
apps/server/scripts/run/deprecated.sh
apps/server/src/app.ts
apps/server/src/database/queries/privateData.ts
apps/server/src/routes/oauth.ts
apps/server/src/routes/search.ts
apps/server/src/tools/crypto.ts
apps/server/src/tools/env.ts
apps/server/src/tools/oauth/Provider.ts
monorepo.code-workspace
Commit message: Merge pull request from GHSA-hfgf-99p3-6fjj

* More secure OAuth, fixed frame ancestors

* Change generateRandomString to return base64-encoded entropy

While the old implementation also seems to yield correct results, it is
(at least in my opinion) not obvious that it is correct and sufficiently
secure for the following reasons:

1. It takes the output string length as a parameter instead of the
   required amount of entropy.
   For cryptographic applications, it is important to know how much
   entropy any generated secret contains. Therefore, the input to the
   function should be the required bytes of entropy instead of the
   output string length.

2. While it uses a rather simple algorithm to convert the entropy bytes
   to a string, using a well-known and dead simple encoding such as
   base64 (or hexadecimal, but base64 is more compact) is even easier to
   verify as "obviously correct". In this case URL-safe base64 is chosen
   because the values are also used in URLs.

All usages of the function were also changed to accomodate the parameter
change to requested bytes of entropy. For all of them 32 bytes (256
bits) of entropy were chosen, which nicely fits the purpose of being
used for SHA256 and is definitely enough entropy for things like OAuth
'state'.

* Apply more restrictive CSP and prevent MIME sniffing for backend

As the backend doesn't show HTML pages to users, a very restrictive CSP
can be used. I'm currently not aware of any active issues, but being as
restrictive as possible doesn't hurt, which is what default-src 'none'
does.

Also, set "X-Content-Type-Options: nosniff" to prevent MIME sniffing in
browsers, which can also be an issue in certain circumstances.

* Implement a basic Content-Security-Policy in the client

This commit implements a basic CSP in the client by setting it as a
<meta> tag in index.html. Normally it would be preferred to set the CSP
in a HTTP header, but the react dev server does not seem to support
that. However, it is very important to have the CSP active during
development to catch errors caused by a restrictive CSP before deploying
to production.

Note that a header-based CSP is still required to set
"frame-ancestors 'none'" to prevent clickjacking. It is perfectly fine
to set the CSP both in the <meta> tag and in the HTTP header, both CSPs
will be applied.

There is still room for improvement in the CSP. With some effort, the
script-src could be further restricted using a combination of hashes or
nonces and 'strict-dynamic' to get rid of 'self'. The connect-src could
also be restricted to only the backend origin.

That being said, this is still a very good starting point and much
better than not having a CSP at all.

* Prevent MIME sniffing for the client in production

Prevent MIME sniffing for the client in production by setting the header
"X-Content-Type-Options: nosniff".

* Use only the origin of CLIENT_ENDPOINT for CORS policy

When the full CLIENT_ENDPOINT is used to set the CORS policy, the policy
does not get applied correctly when the client is served under a
subdirectory. Always using the origin fixes this.

* removed pkce, connect-src is dynamic

* ensuring API_ENDPOINT ends with a /

* make sure api endpoint ends with a slash, updated CORS notice

* fixed comment on index.html

---------

Co-authored-by: Marius Renner <marius@mariusrenner.de>

Vulnerable code:
| CORS                  | _not defined_ | List of comma-separated origin allowed, or _nothing_ to allow any origin |
You can edit the CORS for the server:

- `all` will allow every source.
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <script src="%PUBLIC_URL%/variables.js"></script>

    <title>Your Spotify</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />

    <meta name="title" content="Your Spotify">
    <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">

    <meta property="og:type" content="image/png">
    <meta property="og:title" content="Your Spotify">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="628">
    <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
    <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">

    <meta property="twitter:card" content="summary">
    <meta property="twitter:title" content="Your Spotify">
    <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
    <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>
</html>
serve -s -l tcp://0.0.0.0:3000 /app/apps/client/build/
  echo "Setting CORS to 'all' is not needed anymore, omitting it is the new way of authorizing every origin."
let corsValue = get("CORS")?.split(",");
if (corsValue?.[0] === "all") {
import { randomUUID } from "crypto";
  await PrivateDataModel.create({ jwtPrivateKey: randomUUID() });
import { Router } from "express";
import { GlobalPreferencesRequest, SpotifyRequest } from "../tools/types";
router.get("/spotify", async (_, res) => {
    res.cookie("token", token);
  res.redirect(Spotify.getRedirect());
router.get("/spotify/callback", withGlobalPreferences, async (req, res) => {
  const { query, globalPreferences } = req as GlobalPreferencesRequest;
  const { code } = query;
  const infos = await Spotify.exchangeCode(code as string);
  try {
    const client = Spotify.getHttpClient(infos.accessToken);
    const { data: spotifyMe } = await client.get("/me");
    let user = await getUserFromField("spotifyId", spotifyMe.id, false);
    if (!user) {
      if (!globalPreferences.allowRegistrations) {
        return res.redirect(`${get("CLIENT_ENDPOINT")}/registrations-disabled`);
      const nbUsers = await getUserCount();
      user = await createUser(
        spotifyMe.display_name,
        spotifyMe.id,
        nbUsers === 0,
    await storeInUser("_id", user._id, infos);
    const privateData = await getPrivateData();
    if (!privateData?.jwtPrivateKey) {
      throw new Error("No private data found, cannot sign JWT");
    }
    const token = sign(
      { userId: user._id.toString() },
      privateData.jwtPrivateKey,
      {
        expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
      },
    );
    res.cookie("token", token);
  } catch (e) {
    logger.error(e);
  }
  return res.redirect(get("CLIENT_ENDPOINT"));
});
import { Router } from 'express';
import { z } from 'zod';
import { searchArtist, searchTrack } from '../database';
import { logger } from '../tools/logger';
import { isLoggedOrGuest, validating } from '../tools/middleware';
import { TypedPayload } from '../tools/types';
import { searchAlbum } from '../database/queries/album';
        searchAlbum(query)
import { logger } from "./logger";
  static exchangeCode = code => {};
  static getRedirect = () => {
    return `https://accounts.spotify.com/authorize?response_type=code&client_id=${
      credentials.spotify.public
    }${
      scopes ? `&scope=${encodeURIComponent(scopes)}` : ""
    }&redirect_uri=${encodeURIComponent(redirectUri)}`;
  static exchangeCode = async (code: string) => {

Fixed code:
| CORS                  | _not defined_ | List of comma-separated origin allowed |
- Not defining it will default to authorize only the `CLIENT_ENDPOINT` origin.
> If you really want to allow every origin no matter what, you can set the `CORS` value to `i-want-a-security-vulnerability-and-want-to-allow-all-origins`.

<head>
  <meta charset="utf-8" />

  <!--
    While setting the CSP in HTTP headers is preferred, there does not seem to
    be a good way to do this in the react dev environment, so it is set here as
    a meta tag. Note that frame-ancestors CANNOT be set in the meta tag and
    MUST be set as a HTTP header in production!

    Restricting connect-src is done at start of the client server.
    -->
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' https://i.scdn.co; connect-src *;" />

  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
  <script src="%PUBLIC_URL%/variables.js"></script>

  <title>Your Spotify</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />

  <meta name="title" content="Your Spotify">
  <meta name="description" content="Keep track of your Spotify listening habits with Your Spotify.">

  <meta property="og:type" content="image/png">
  <meta property="og:title" content="Your Spotify">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="628">
  <meta property="og:description" content="Keep track of your Spotify listening habits with Your Spotify.">
  <meta property="og:image" content="http://localhost:8080/static/your_spotify_1200.png">

  <meta property="twitter:card" content="summary">
  <meta property="twitter:title" content="Your Spotify">
  <meta property="twitter:description" content="Keep track of your Spotify listening habits with Your Spotify.">
  <meta property="twitter:image" content="http://localhost:8080/static/your_spotify_1200.png">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
</body>

</html>
{
  "headers": [
    {
      "source": "*",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "frame-ancestors 'none';"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        }
      ]
    }
  ]
}
serve -c /app/apps/client/scripts/run/serve.json -s -l tcp://0.0.0.0:3000 /app/apps/client/build/

    # Restricting connect-src to API_ENDPOINT with a trailing /
    API_ENDPOINT_ENDING_WITH_SLASH=$API_ENDPOINT
    if [[ "$API_ENDPOINT_ENDING_WITH_SLASH" != */ ]]
    then
        API_ENDPOINT_ENDING_WITH_SLASH="$API_ENDPOINT_ENDING_WITH_SLASH/"
    fi
    sed -i "s#connect-src \(.*\);#connect-src $API_ENDPOINT_ENDING_WITH_SLASH;#g" "$VAR_PATH/index.html"
  echo "Setting CORS to 'all' is not authorized anymore. To allow all sources, please specify CORS=i-want-a-security-vulnerability-and-want-to-allow-all-origins"
  exit 1
const ALLOW_ALL_CORS =
  "i-want-a-security-vulnerability-and-want-to-allow-all-origins";
let corsValue: string[] | undefined = get("CORS")?.split(",") ?? [
  new URL(get("CLIENT_ENDPOINT")).origin,
];
if (corsValue?.[0] === ALLOW_ALL_CORS) {
app.use((_, res, next) => {
  // Apply security headers for the whole backend here

  // Apply a restrictive CSP for the server API just in case. As there isn't any
  // HTML content here, "default-src 'none'" is a good deny-all default in case
  // an attacker tries something funny.
  // "frame-ancestors 'none'" is required because frame-ancestors doesn't fall
  // back to default-src and nobody has legitimate business framing the backend.
  res.header(
    "Content-Security-Policy",
    "default-src 'none'; object-src 'none'; frame-ancestors 'none';"
  );

  // Prevent MIME sniffing in browsers
  res.header("X-Content-Type-Options", "nosniff");
  next();
});

import { generateRandomString } from "../../tools/crypto";
  await PrivateDataModel.create({ jwtPrivateKey: generateRandomString(32) });
import { Request, Response, Router } from "express";
import { z } from "zod";
  validating,
import {
  GlobalPreferencesRequest,
  SpotifyRequest,
  TypedPayload,
} from "../tools/types";
function storeTokenInCookie(
  request: Request,
  response: Response,
  token: string,
) {
  response.cookie("token", token, {
    sameSite: "strict",
    httpOnly: true,
    secure: request.secure,
  });
}

const OAUTH_COOKIE_NAME = "oauth";
const spotifyCallbackOAuthCookie = z.object({
  state: z.string(),
});
type OAuthCookie = z.infer<typeof spotifyCallbackOAuthCookie>;

router.get("/spotify", async (req, res) => {
    storeTokenInCookie(req, res, token);
  const { url, state } = await Spotify.getRedirect();
  const oauthCookie: OAuthCookie = {
    state,
  };

  res.cookie(OAUTH_COOKIE_NAME, oauthCookie, {
    sameSite: "lax",
    httpOnly: true,
    secure: req.secure,
  });

  res.redirect(url);
const spotifyCallback = z.object({
  code: z.string(),
  state: z.string(),
});
router.get(
  "/spotify/callback",
  validating(spotifyCallback, "query"),
  withGlobalPreferences,
  async (req, res) => {
    const { query, globalPreferences } = req as GlobalPreferencesRequest;
    const { code, state } = query as TypedPayload<typeof spotifyCallback>;
    try {
      const cookie = spotifyCallbackOAuthCookie.parse(
        req.cookies[OAUTH_COOKIE_NAME],
      );

      if (state !== cookie.state) {
        throw new Error("State does not match");

      const infos = await Spotify.exchangeCode(code, cookie.state);

      const client = Spotify.getHttpClient(infos.accessToken);
      const { data: spotifyMe } = await client.get("/me");
      let user = await getUserFromField("spotifyId", spotifyMe.id, false);
      if (!user) {
        if (!globalPreferences.allowRegistrations) {
          return res.redirect(
            `${get("CLIENT_ENDPOINT")}/registrations-disabled`,
          );
        }
        const nbUsers = await getUserCount();
        user = await createUser(
          spotifyMe.display_name,
          spotifyMe.id,
          nbUsers === 0,
        );
      }
      await storeInUser("_id", user._id, infos);
      const privateData = await getPrivateData();
      if (!privateData?.jwtPrivateKey) {
        throw new Error("No private data found, cannot sign JWT");
      }
      const token = sign(
        { userId: user._id.toString() },
        privateData.jwtPrivateKey,
        {
          expiresIn: getWithDefault("COOKIE_VALIDITY_MS", "1h"),
        },
      storeTokenInCookie(req, res, token);
    } catch (e) {
      logger.error(e);
    } finally {
      res.clearCookie(OAUTH_COOKIE_NAME);
    return res.redirect(get("CLIENT_ENDPOINT"));
  },
);
import { Router } from "express";
import { z } from "zod";
import { searchArtist, searchTrack } from "../database";
import { logger } from "../tools/logger";
import { isLoggedOrGuest, validating } from "../tools/middleware";
import { TypedPayload } from "../tools/types";
import { searchAlbum } from "../database/queries/album";
        searchAlbum(query),
import { getRandomValues, subtle } from "crypto";

export function generateRandomString(entropyBytes: number) {
  const entropy = getRandomValues(new Uint8Array(entropyBytes));
  return Buffer.from(entropy).toString("base64url");
}

export async function sha256(plain: string) {
  const encoder = new TextEncoder();
  const data = encoder.encode(plain);
  return subtle.digest("SHA-256", data);
}
import { generateRandomString, sha256 } from "../crypto";
  static exchangeCode = (code: string, state: string) => {};
  static getRedirect = async () => {
    const authorizeUrl = new URL("https://accounts.spotify.com/authorize");
    const state = generateRandomString(32);

    authorizeUrl.searchParams.append("client_id", credentials.spotify.public);
    authorizeUrl.searchParams.append("response_type", "code");
    authorizeUrl.searchParams.append("redirect_uri", redirectUri);
    authorizeUrl.searchParams.append("state", state);
    authorizeUrl.searchParams.append("scope", scopes);

    return {
      url: authorizeUrl.toString(),
      state,
    };
  static exchangeCode = async (code: string, state: string) => {
          state,
    },
    "[json]": {
      "editor.defaultFormatter": "vscode.json-language-features"
    },
    "[html]": {
      "editor.defaultFormatter": "vscode.html-language-features"